Merge pull request #73 from ciphera-net/staging

Dashboard shell, breadcrumb navigation, sidebar redesign & integration pages SEO overhaul
This commit is contained in:
Usman
2026-03-29 00:30:29 +01:00
committed by GitHub
52 changed files with 3090 additions and 4682 deletions

3
.gitignore vendored
View File

@@ -1,5 +1,8 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# auto-generated
/lib/integration-guides.gen.ts
# dependencies
/node_modules
/.pnp

View File

@@ -1,46 +1,56 @@
/**
* @file Dynamic route for individual integration guide pages.
*
* Handles all 50 integration routes via [slug].
* Renders MDX content from content/integrations/*.mdx via next-mdx-remote.
* Exports generateStaticParams for static generation and
* generateMetadata for per-page SEO (title, description, OG, JSON-LD).
*/
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { MDXRemote } from 'next-mdx-remote/rsc'
import remarkGfm from 'remark-gfm'
import { CodeBlock } from '@ciphera-net/ui'
import { integrations, getIntegration } from '@/lib/integrations'
import { getGuideContent } from '@/lib/integration-guides'
import { getIntegrationGuide } from '@/lib/integration-content'
import { IntegrationGuide } from '@/components/IntegrationGuide'
// * ─── Static Params ───────────────────────────────────────────────
export function generateStaticParams() {
return integrations.map((i) => ({ slug: i.id }))
// * ─── MDX Components ────────────────────────────────────────────
const mdxComponents = {
CodeBlock,
}
// * ─── SEO Metadata ────────────────────────────────────────────────
// * ─── Static Params ─────────────────────────────────────────────
export function generateStaticParams() {
return integrations
.filter((i) => i.dedicatedPage)
.map((i) => ({ slug: i.id }))
}
// * ─── SEO Metadata ──────────────────────────────────────────────
interface PageProps {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params
const integration = getIntegration(slug)
if (!integration) return {}
const guide = getIntegrationGuide(slug)
if (!guide) return {}
const title = `How to Add Pulse Analytics to ${integration.name} | Pulse by Ciphera`
const description = integration.seoDescription
const url = `https://pulse.ciphera.net/integrations/${integration.id}`
const title = `How to Add Pulse Analytics to ${guide.title} | Pulse by Ciphera`
const description = guide.description
const url = `https://pulse.ciphera.net/integrations/${guide.slug}`
return {
title,
description,
keywords: [
`${integration.name} analytics`,
`${integration.name} Pulse`,
`${guide.title} analytics`,
`${guide.title} Pulse`,
'privacy-first analytics',
'website analytics',
'Ciphera Pulse',
integration.name,
guide.title,
],
alternates: { canonical: url },
openGraph: {
@@ -58,21 +68,19 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
}
}
// * ─── Page Component ──────────────────────────────────────────────
// * ─── Page Component ────────────────────────────────────────────
export default async function IntegrationPage({ params }: PageProps) {
const { slug } = await params
const integration = getIntegration(slug)
if (!integration) return notFound()
const content = getGuideContent(slug)
if (!content) return notFound()
const guide = getIntegrationGuide(slug)
if (!integration || !guide) return notFound()
// * HowTo JSON-LD for rich search snippets
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: `How to Add Pulse Analytics to ${integration.name}`,
description: integration.seoDescription,
description: guide.description,
step: [
{
'@type': 'HowToStep',
@@ -104,7 +112,11 @@ export default async function IntegrationPage({ params }: PageProps) {
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<IntegrationGuide integration={integration}>
{content}
<MDXRemote
source={guide.content}
components={mdxComponents}
options={{ mdxOptions: { remarkPlugins: [remarkGfm] } }}
/>
</IntegrationGuide>
</>
)

View File

@@ -1,128 +0,0 @@
'use client'
import Link from 'next/link'
import { ArrowLeftIcon } from '@ciphera-net/ui'
export default function NextJsIntegrationPage() {
return (
<div className="relative min-h-screen flex flex-col overflow-hidden">
{/* * --- ATMOSPHERE (Background) --- */}
<div className="absolute inset-0 -z-10 pointer-events-none">
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
<div
className="absolute inset-0 bg-grid-pattern opacity-[0.05]"
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
/>
</div>
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
<Link
href="/integrations"
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
>
<ArrowLeftIcon className="w-4 h-4 mr-2" />
Back to Integrations
</Link>
<div className="flex items-center gap-4 mb-8">
<div className="p-3 bg-neutral-800 rounded-xl">
<svg viewBox="0 0 128 128" className="w-10 h-10 invert">
<path d="M64 0C28.7 0 0 28.7 0 64s28.7 64 64 64 64-28.7 64-64S99.3 0 64 0zm27.6 93.9c-.8.9-2.2 1-3.1.2L42.8 52.8V88c0 1.3-1.1 2.3-2.3 2.3h-7.4c-1.3 0-2.3-1.1-2.3-2.3V40c0-1.3 1.1-2.3 2.3-2.3h7.4c1 0 1.9.6 2.2 1.5l48.6 44.8V40c0-1.3 1.1-2.3 2.3-2.3h7.4c1.3 0 2.3 1.1 2.3 2.3v48c0 1.3-1.1 2.3-2.3 2.3h-6.8c-.9 0-1.7-.5-2.1-1.3z" />
</svg>
</div>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-white">
Next.js Integration
</h1>
</div>
<div className="prose prose-invert max-w-none">
<p className="lead text-xl text-neutral-400">
The best way to add Pulse to your Next.js application is using the built-in <code>next/script</code> component.
</p>
<hr className="my-8 border-neutral-800" />
<h3>Using App Router (Recommended)</h3>
<p>
Add the script to your root layout file (usually <code>app/layout.tsx</code> or <code>app/layout.js</code>).
</p>
<div className="bg-neutral-900 rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
<span className="text-xs text-neutral-400 font-mono">app/layout.tsx</span>
</div>
<div className="p-4 overflow-x-auto">
<pre className="text-sm font-mono text-neutral-300">
{`import Script from 'next/script'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<head>
<Script
defer
src="https://pulse.ciphera.net/script.js"
data-domain="your-site.com"
strategy="afterInteractive"
/>
</head>
<body>{children}</body>
</html>
)
}`}
</pre>
</div>
</div>
<h3>Using Pages Router</h3>
<p>
If you are using the older Pages Router, add the script to your custom <code>_app.tsx</code> or <code>_document.tsx</code>.
</p>
<div className="bg-neutral-900 rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
<span className="text-xs text-neutral-400 font-mono">pages/_app.tsx</span>
</div>
<div className="p-4 overflow-x-auto">
<pre className="text-sm font-mono text-neutral-300">
{`import Script from 'next/script'
import type { AppProps } from 'next/app'
export default function App({ Component, pageProps }: AppProps) {
return (
<>
<Script
defer
src="https://pulse.ciphera.net/script.js"
data-domain="your-site.com"
strategy="afterInteractive"
/>
<Component {...pageProps} />
</>
)
}`}
</pre>
</div>
</div>
<h3>Configuration Options</h3>
<ul>
<li>
<strong>data-domain</strong>: The domain name you added to your Pulse dashboard (e.g., <code>example.com</code>).
</li>
<li>
<strong>src</strong>: The URL of our tracking script: <code>https://pulse.ciphera.net/script.js</code>
</li>
<li>
<strong>strategy</strong>: We recommend <code>afterInteractive</code> to ensure it loads quickly without blocking hydration.
</li>
</ul>
</div>
</div>
</div>
)
}

View File

@@ -244,7 +244,7 @@ export default function IntegrationsPage() {
transition={{ duration: 0.4, delay: i * 0.05 }}
>
<Link
href={`/integrations/${integration!.id}`}
href={integration!.dedicatedPage ? `/integrations/${integration!.id}` : '/integrations/script-tag'}
className="group flex items-center gap-3 p-4 bg-neutral-900/50 backdrop-blur-sm border border-neutral-800 rounded-xl hover:border-brand-orange/50 transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg h-full"
>
<div className="p-2 bg-neutral-800 rounded-lg shrink-0 group-hover:scale-110 transition-transform duration-300 [&_svg]:w-6 [&_svg]:h-6">
@@ -283,7 +283,7 @@ export default function IntegrationsPage() {
transition={{ duration: 0.5, delay: i * 0.05 }}
>
<Link
href={`/integrations/${integration.id}`}
href={integration.dedicatedPage ? `/integrations/${integration.id}` : '/integrations/script-tag'}
className="group relative p-6 bg-neutral-900/50 backdrop-blur-sm border border-neutral-800 rounded-2xl hover:border-brand-orange/50 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl block h-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
>
<div className="flex items-start justify-between mb-6">

View File

@@ -1,118 +0,0 @@
'use client'
import Link from 'next/link'
import { ArrowLeftIcon } from '@ciphera-net/ui'
export default function ReactIntegrationPage() {
return (
<div className="relative min-h-screen flex flex-col overflow-hidden">
{/* * --- ATMOSPHERE (Background) --- */}
<div className="absolute inset-0 -z-10 pointer-events-none">
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
<div
className="absolute inset-0 bg-grid-pattern opacity-[0.05]"
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
/>
</div>
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
<Link
href="/integrations"
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
>
<ArrowLeftIcon className="w-4 h-4 mr-2" />
Back to Integrations
</Link>
<div className="flex items-center gap-4 mb-8">
<div className="p-3 bg-neutral-800 rounded-xl">
<svg viewBox="0 0 128 128" className="w-10 h-10 text-[#61DAFB] fill-current">
<path d="M64 10.6c18.4 0 34.6 5.8 44.6 14.8 6.4 5.8 10.2 12.8 10.2 20.6 0 21.6-28.6 41.2-64 41.2-1.6 0-3.2-.1-4.8-.2-1.2 10.8-6.2 20.2-13.8 27.6-8.8 8.6-20.6 13.4-33.2 13.4-2.2 0-4.4-.2-6.4-.4 10.2-12.8 15.6-29.2 15.6-46.2 0-2.6-.2-5.2-.4-7.8 13.6-1.6 26.2-5.4 37.4-11 11.2-5.6 20.2-13 26.2-21.4-6.4-5.8-15.4-10-25.6-12.2-10.2-2.2-21.4-3.4-33-3.4-1.6 0-3.2.1-4.8.2 1.2-10.8 6.2-20.2 13.8-27.6 8.8-8.6 20.6-13.4 33.2-13.4 2.2 0 4.4.2 6.4.4-10.2 12.8-15.6 29.2-15.6 46.2 0 2.6.2 5.2.4 7.8-13.6 1.6-26.2 5.4-37.4 11-11.2 5.6-20.2 13-26.2 21.4 6.4 5.8 15.4 10 25.6 12.2 10.2 2.2 21.4 3.4 33 3.4 1.6 0 3.2-.1 4.8-.2-1.2 10.8-6.2 20.2-13.8 27.6-8.8 8.6-20.6 13.4-33.2 13.4-2.2 0-4.4-.2-6.4-.4 10.2-12.8 15.6-29.2 15.6-46.2 0-2.6-.2-5.2-.4-7.8 13.6-1.6 26.2-5.4 37.4-11zm-33.4 62c-11.2 5.6-20.2 13-26.2 21.4 6.4 5.8 15.4 10 25.6 12.2 10.2 2.2 21.4 3.4 33 3.4 1.6 0 3.2-.1 4.8-.2-1.2 10.8-6.2 20.2-13.8 27.6-8.8 8.6-20.6 13.4-33.2 13.4-2.2 0-4.4-.2-6.4-.4 10.2-12.8 15.6-29.2 15.6-46.2 0-2.6-.2-5.2-.4-7.8 13.6-1.6 26.2-5.4 37.4-11zm-15.2-16.6c-6.4-5.8-10.2-12.8-10.2-20.6 0-21.6 28.6-41.2 64-41.2 1.6 0 3.2.1 4.8.2 1.2-10.8 6.2-20.2 13.8-27.6 8.8-8.6 20.6-13.4 33.2-13.4 2.2 0 4.4.2 6.4.4-10.2 12.8-15.6 29.2-15.6 46.2 0 2.6.2 5.2.4 7.8-13.6 1.6-26.2 5.4-37.4 11-11.2 5.6-20.2 13-26.2 21.4 6.4 5.8 15.4 10 25.6 12.2 10.2 2.2 21.4 3.4 33 3.4 1.6 0 3.2-.1 4.8-.2-1.2 10.8-6.2 20.2-13.8 27.6-8.8 8.6-20.6 13.4-33.2 13.4-2.2 0-4.4-.2-6.4-.4 10.2-12.8 15.6-29.2 15.6-46.2 0-2.6-.2-5.2-.4-7.8z" />
<circle cx="64" cy="64" r="10.6" />
</svg>
</div>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-white">
React Integration
</h1>
</div>
<div className="prose prose-invert max-w-none">
<p className="lead text-xl text-neutral-400">
For standard React SPAs (Create React App, Vite, etc.), you can simply add the script tag to your <code>index.html</code>.
</p>
<hr className="my-8 border-neutral-800" />
<h3>Method 1: index.html (Recommended)</h3>
<p>
The simplest way is to add the script tag directly to the <code>&lt;head&gt;</code> of your <code>index.html</code> file.
</p>
<div className="bg-neutral-900 rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
<span className="text-xs text-neutral-400 font-mono">public/index.html</span>
</div>
<div className="p-4 overflow-x-auto">
<pre className="text-sm font-mono text-neutral-300">
{`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Pulse Analytics -->
<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>
<title>My React App</title>
</head>
<body>
<div id="root"></div>
</body>
</html>`}
</pre>
</div>
</div>
<h3>Method 2: Programmatic Injection</h3>
<p>
If you need to load the script dynamically (e.g., only in production), you can use a <code>useEffect</code> hook in your main App component.
</p>
<div className="bg-neutral-900 rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
<span className="text-xs text-neutral-400 font-mono">src/App.tsx</span>
</div>
<div className="p-4 overflow-x-auto">
<pre className="text-sm font-mono text-neutral-300">
{`import { useEffect } from 'react'
function App() {
useEffect(() => {
// Only load in production
if (process.env.NODE_ENV === 'production') {
const script = document.createElement('script')
script.defer = true
script.setAttribute('data-domain', 'your-site.com')
script.src = 'https://pulse.ciphera.net/script.js'
document.head.appendChild(script)
}
}, [])
return (
<div className="App">
<h1>Hello World</h1>
</div>
)
}`}
</pre>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,147 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { ArrowLeftIcon } from '@ciphera-net/ui'
import { CodeBlock } from '@ciphera-net/ui'
export const metadata: Metadata = {
title: 'Add Pulse Analytics to Any Website | Pulse by Ciphera',
description: 'Add privacy-first analytics to any website with a single script tag. Works with any platform, CMS, or framework.',
alternates: { canonical: 'https://pulse.ciphera.net/integrations/script-tag' },
openGraph: {
title: 'Add Pulse Analytics to Any Website | Pulse by Ciphera',
description: 'Add privacy-first analytics to any website with a single script tag.',
url: 'https://pulse.ciphera.net/integrations/script-tag',
siteName: 'Pulse by Ciphera',
type: 'article',
},
}
export default function ScriptTagPage() {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: 'How to Add Pulse Analytics to Any Website',
description: 'Add privacy-first analytics to any website with a single script tag.',
step: [
{
'@type': 'HowToStep',
name: 'Copy the script tag',
text: 'Copy the Pulse tracking script with your domain.',
},
{
'@type': 'HowToStep',
name: 'Paste into your HTML head',
text: 'Add the script tag inside the <head> section of your website.',
},
{
'@type': 'HowToStep',
name: 'Deploy and verify',
text: 'Deploy your site and check the Pulse dashboard for incoming data.',
},
],
tool: {
'@type': 'HowToTool',
name: 'Pulse by Ciphera',
url: 'https://pulse.ciphera.net',
},
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<div className="relative min-h-screen flex flex-col overflow-hidden">
<div className="absolute inset-0 -z-10 pointer-events-none">
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
<div
className="absolute inset-0 bg-grid-pattern opacity-[0.05]"
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
/>
</div>
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
<Link
href="/integrations"
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
>
<ArrowLeftIcon className="w-4 h-4 mr-2" />
Back to Integrations
</Link>
<div className="flex items-center gap-4 mb-8">
<div className="p-3 bg-neutral-800 rounded-xl">
<svg className="w-10 h-10 text-brand-orange" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" />
</svg>
</div>
<h1 className="text-4xl md:text-5xl font-bold text-white">
Script Tag Integration
</h1>
</div>
<div className="prose prose-invert max-w-none">
<p className="lead text-xl text-neutral-400">
Add Pulse to any website by pasting a single script tag into your HTML.
This works with any platform, CMS, or static site.
</p>
<hr className="my-8 border-neutral-800" />
<h2>Installation</h2>
<p>
Add the following script tag inside the <code>&lt;head&gt;</code> section of your website:
</p>
<CodeBlock filename="index.html">{`<head>
<!-- ... other head elements ... -->
<script
defer
src="https://pulse.ciphera.net/script.js"
data-domain="your-site.com"
></script>
</head>`}</CodeBlock>
<h2>Configuration</h2>
<ul>
<li><code>data-domain</code> &mdash; your site&apos;s domain as shown in your Pulse dashboard (e.g. <code>example.com</code>), without <code>https://</code></li>
<li><code>defer</code> &mdash; loads the script without blocking page rendering</li>
</ul>
<h2>Where to paste the script</h2>
<p>
Most platforms have a &ldquo;Custom Code&rdquo;, &ldquo;Code Injection&rdquo;, or &ldquo;Header Scripts&rdquo;
section in their settings. Look for one of these:
</p>
<ul>
<li><strong>Squarespace:</strong> Settings &rarr; Developer Tools &rarr; Code Injection &rarr; Header</li>
<li><strong>Wix:</strong> Settings &rarr; Custom Code &rarr; Head</li>
<li><strong>Webflow:</strong> Project Settings &rarr; Custom Code &rarr; Head Code</li>
<li><strong>Ghost:</strong> Settings &rarr; Code Injection &rarr; Site Header</li>
<li><strong>Any HTML site:</strong> Paste directly into your <code>&lt;head&gt;</code> tag</li>
</ul>
<h2>Verify installation</h2>
<p>
After deploying, visit your site and check the Pulse dashboard. You should
see your first page view within a few seconds.
</p>
<hr className="my-8 border-neutral-800" />
<h3>Optional: Frustration Tracking</h3>
<p>
Detect rage clicks and dead clicks by adding the frustration tracking
add-on after the core script:
</p>
<CodeBlock filename="index.html">{`<script defer src="https://pulse.ciphera.net/script.frustration.js"></script>`}</CodeBlock>
<p>
No extra configuration needed. Add <code>data-no-rage</code> or{' '}
<code>data-no-dead</code> to disable individual signals.
</p>
</div>
</div>
</div>
</>
)
}

View File

@@ -1,112 +0,0 @@
'use client'
import Link from 'next/link'
import { ArrowLeftIcon } from '@ciphera-net/ui'
export default function VueIntegrationPage() {
return (
<div className="relative min-h-screen flex flex-col overflow-hidden">
{/* * --- ATMOSPHERE (Background) --- */}
<div className="absolute inset-0 -z-10 pointer-events-none">
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
<div
className="absolute inset-0 bg-grid-pattern opacity-[0.05]"
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
/>
</div>
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
<Link
href="/integrations"
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
>
<ArrowLeftIcon className="w-4 h-4 mr-2" />
Back to Integrations
</Link>
<div className="flex items-center gap-4 mb-8">
<div className="p-3 bg-neutral-800 rounded-xl">
<svg viewBox="0 0 128 128" className="w-10 h-10 text-[#4FC08D] fill-current">
<path d="M82.8 24.6h27.8L64 103.4 17.4 24.6h27.8L64 59.4l18.8-34.8z" />
<path d="M64 24.6H39L64 67.4l25-42.8H64z" fill="#35495E" />
</svg>
</div>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-white">
Vue.js Integration
</h1>
</div>
<div className="prose prose-invert max-w-none">
<p className="lead text-xl text-neutral-400">
Integrating Pulse with Vue.js is straightforward. You can add the script to your <code>index.html</code> file.
</p>
<hr className="my-8 border-neutral-800" />
<h3>Method 1: index.html (Recommended)</h3>
<p>
Add the script tag to the <code>&lt;head&gt;</code> section of your <code>index.html</code> file. This works for both Vue 2 and Vue 3 projects created with Vue CLI or Vite.
</p>
<div className="bg-neutral-900 rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
<span className="text-xs text-neutral-400 font-mono">index.html</span>
</div>
<div className="p-4 overflow-x-auto">
<pre className="text-sm font-mono text-neutral-300">
{`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Pulse Analytics -->
<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>
<title>My Vue App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>`}
</pre>
</div>
</div>
<h3>Method 2: Nuxt.js</h3>
<p>
For Nuxt.js applications, you should add the script to your <code>nuxt.config.js</code> or <code>nuxt.config.ts</code> file.
</p>
<div className="bg-neutral-900 rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
<span className="text-xs text-neutral-400 font-mono">nuxt.config.ts</span>
</div>
<div className="p-4 overflow-x-auto">
<pre className="text-sm font-mono text-neutral-300">
{`export default defineNuxtConfig({
app: {
head: {
script: [
{
src: 'https://pulse.ciphera.net/script.js',
defer: true,
'data-domain': 'your-site.com'
}
]
}
}
})`}
</pre>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,80 +0,0 @@
'use client'
import Link from 'next/link'
import { ArrowLeftIcon } from '@ciphera-net/ui'
export default function WordPressIntegrationPage() {
return (
<div className="relative min-h-screen flex flex-col overflow-hidden">
{/* * --- ATMOSPHERE (Background) --- */}
<div className="absolute inset-0 -z-10 pointer-events-none">
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
<div
className="absolute inset-0 bg-grid-pattern opacity-[0.05]"
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
/>
</div>
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
<Link
href="/integrations"
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
>
<ArrowLeftIcon className="w-4 h-4 mr-2" />
Back to Integrations
</Link>
<div className="flex items-center gap-4 mb-8">
<div className="p-3 bg-neutral-800 rounded-xl">
<svg viewBox="0 0 128 128" className="w-10 h-10 text-[#21759B] fill-current">
<path d="M116.6 64c0-19.2-10.4-36-26-45.2l28.6 78.4c-1 3.2-2.2 6.2-3.6 9.2-11.4 12.4-27.8 20.2-46 20.2-6.2 0-12.2-.8-17.8-2.4l26.2-76.4c1.2.2 2.4.4 3.6.4 5.4 0 13.8-.8 13.8-.8 2.8-.2 3.2 4 .4 4.2 0 0-2.8.2-6 .4l19 56.6 5.4-18c2.4-7.4 4.2-12.8 4.2-17.4 0-6-2.2-10.2-7.6-12.6-2.8-1.2-2.2-5.4 1.4-5.4h4.4zM64 121.2c-15.8 0-30.2-6.4-40.8-16.8L46.6 36.8c-2.8-.2-5.8-.4-5.8-.4-2.8-.2-2.4-4.4.4-4.2 0 0 8.4.8 13.6.8 5.4 0 13.6-.8 13.6-.8 2.8-.2 3.2 4 .4 4.2 0 0-2.8.2-5.8.4l18.2 54.4 10.6-31.8L64 121.2zM11.4 64c0 17 8.2 32.2 20.8 41.8L18.8 66.8c-.8-3.4-1.2-6.6-1.2-9.2 0-6.8 2.6-13 6.2-17.8C15.6 47.4 11.4 55.2 11.4 64zM64 6.8c16.2 0 30.8 6.8 41.4 17.6-1.4-.2-2.8-.2-4.2-.2-7.8 0-14.2 1.4-14.2 1.4-2.8.6-2.2 4.8.6 4.2 0 0 5-1 10.6-1 2.2 0 4.6.2 6.6.4L88.2 53 71.4 6.8h-7.4z" />
</svg>
</div>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-white">
WordPress Integration
</h1>
</div>
<div className="prose prose-invert max-w-none">
<p className="lead text-xl text-neutral-400">
You can add Pulse to your WordPress site without installing any heavy plugins, or by using a simple code snippet plugin.
</p>
<hr className="my-8 border-neutral-800" />
<h3>Method 1: Using a Plugin (Easiest)</h3>
<ol>
<li>Install a plugin like "Insert Headers and Footers" (WPCode).</li>
<li>Go to the plugin settings and find the "Scripts in Header" section.</li>
<li>Paste the following code snippet:</li>
</ol>
<div className="bg-neutral-900 rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
<span className="text-xs text-neutral-400 font-mono">Header Script</span>
</div>
<div className="p-4 overflow-x-auto">
<pre className="text-sm font-mono text-neutral-300">
{`<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>`}
</pre>
</div>
</div>
<h3>Method 2: Edit Theme Files (Advanced)</h3>
<p>
If you are comfortable editing your theme files, you can add the script directly to your <code>header.php</code> file.
</p>
<ol>
<li>Go to Appearance &gt; Theme File Editor.</li>
<li>Select <code>header.php</code> from the right sidebar.</li>
<li>Paste the script tag just before the closing <code>&lt;/head&gt;</code> tag.</li>
</ol>
</div>
</div>
</div>
)
}

View File

@@ -17,6 +17,7 @@ import { LoadingOverlay } from '@ciphera-net/ui'
import { useRouter } from 'next/navigation'
import { UnifiedSettingsProvider, useUnifiedSettings } from '@/lib/unified-settings-context'
import UnifiedSettingsModal from '@/components/settings/unified/UnifiedSettingsModal'
import DashboardShell from '@/components/dashboard/DashboardShell'
const ORG_SWITCH_KEY = 'pulse_switching_org'
@@ -95,6 +96,8 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
const showOfflineBar = Boolean(auth.user && !isOnline)
// Site pages use DashboardShell with full sidebar — no Header needed
const isSitePage = pathname.startsWith('/sites/') && pathname !== '/sites/new'
// Pages that use DashboardShell with home sidebar (no site context)
const isDashboardPage = pathname === '/' || pathname.startsWith('/integrations') || pathname === '/pricing'
// Checkout page has its own minimal layout — no app header/footer
const isCheckoutPage = pathname.startsWith('/checkout')
@@ -103,12 +106,11 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
}
// While auth is loading on a site or checkout page, render nothing to prevent flash of public header
if (auth.loading && (isSitePage || isCheckoutPage)) {
if (auth.loading && (isSitePage || isCheckoutPage || isDashboardPage)) {
return null
}
// Authenticated site pages: full sidebar layout
// DashboardShell inside children handles everything
// Authenticated site pages: DashboardShell provided by sites layout
if (isAuthenticated && isSitePage) {
return (
<>
@@ -119,6 +121,17 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
)
}
// Authenticated dashboard pages (home, integrations, pricing): wrap in DashboardShell
if (isAuthenticated && isDashboardPage) {
return (
<>
{showOfflineBar && <OfflineBanner isOnline={isOnline} />}
<DashboardShell siteId={null}>{children}</DashboardShell>
<UnifiedSettingsModal />
</>
)
}
// Checkout page: render children only (has its own layout)
if (isAuthenticated && isCheckoutPage) {
return <>{children}</>

View File

@@ -23,21 +23,16 @@ import PulseFAQ from '@/components/marketing/PulseFAQ'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { getSitesLimitForPlan } from '@/lib/plans'
import { formatDate } from '@/lib/utils/formatDate'
import { useUnifiedSettings } from '@/lib/unified-settings-context'
type SiteStatsMap = Record<string, { stats: Stats }>
export default function HomePage() {
const { user, loading: authLoading } = useAuth()
const { openUnifiedSettings } = useUnifiedSettings()
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)
const [deleteModalSite, setDeleteModalSite] = useState<Site | null>(null)
const [deletedSites, setDeletedSites] = useState<Site[]>([])
const [permanentDeleteSiteModal, setPermanentDeleteSiteModal] = useState<Site | null>(null)
@@ -116,21 +111,13 @@ export default function HomePage() {
const loadSubscription = async () => {
try {
setSubscriptionLoading(true)
const sub = await getSubscription()
setSubscription(sub)
} catch {
setSubscription(null)
} finally {
setSubscriptionLoading(false)
}
}
const handleDelete = (id: string) => {
const site = sites.find((s) => s.id === id)
if (site) setDeleteModalSite(site)
}
const handleRestore = async (id: string) => {
try {
await restoreSite(id)
@@ -236,7 +223,7 @@ export default function HomePage() {
}
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
{showFinishSetupBanner && (
<div className="mb-6 flex items-center justify-between gap-4 rounded-2xl border border-brand-orange/30 bg-brand-orange/10 px-4 py-3">
<p className="text-sm text-neutral-300">
@@ -263,10 +250,10 @@ export default function HomePage() {
</div>
)}
<div className="mb-8 flex items-center justify-between">
<div className="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white">Your Sites</h1>
<p className="mt-1 text-sm text-neutral-400">Manage your analytics sites and view insights.</p>
<h1 className="text-lg font-semibold text-neutral-200 mb-1">Your Sites</h1>
<p className="text-sm text-neutral-400">Manage your analytics sites and view insights.</p>
</div>
{(() => {
const siteLimit = getSitesLimitForPlan(subscription?.plan_id)
@@ -299,80 +286,6 @@ export default function HomePage() {
)}
</div>
{/* * 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="flex min-h-[100px] sm:min-h-[160px] flex-col rounded-2xl border border-neutral-800 bg-neutral-900 p-4">
<p className="text-sm text-neutral-400">Total Sites</p>
<p className="text-2xl font-bold text-white">{sites.length}</p>
</div>
<div className="flex min-h-[100px] sm:min-h-[160px] flex-col rounded-2xl border border-neutral-800 bg-neutral-900 p-4">
<p className="text-sm text-neutral-400">Total Visitors (24h)</p>
<p className="text-2xl font-bold 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="flex min-h-[160px] flex-col rounded-2xl border border-neutral-800 bg-brand-orange/10 p-4">
<p className="text-sm text-brand-orange">Plan & usage</p>
{subscriptionLoading ? (
<div className="animate-pulse space-y-2">
<div className="h-6 w-24 rounded bg-brand-orange/20" />
<div className="h-4 w-full rounded bg-brand-orange/20" />
<div className="h-4 w-3/4 rounded bg-brand-orange/20" />
<div className="h-4 w-20 rounded bg-brand-orange/20 pt-2" />
</div>
) : subscription ? (
<>
<p className="text-lg font-bold text-brand-orange">
{(() => {
const raw =
subscription.plan_id?.startsWith('price_')
? 'Pro'
: subscription.plan_id === 'free' || !subscription.plan_id
? 'Free'
: subscription.plan_id
const label = raw === 'Free' || raw === 'Pro' ? raw : raw.charAt(0).toUpperCase() + raw.slice(1)
return `${label} Plan`
})()}
</p>
{(typeof subscription.sites_count === 'number' || (subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number') || (!subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing'))) && (
<p className="text-sm text-neutral-400 mt-1">
{typeof subscription.sites_count === 'number' && (
<span>Sites: {(() => {
const limit = getSitesLimitForPlan(subscription.plan_id)
return limit != null && typeof subscription.sites_count === 'number' ? `${subscription.sites_count}/${limit}` : subscription.sites_count
})()}</span>
)}
{typeof subscription.sites_count === 'number' && (subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number') && ' · '}
{subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && (
<span>Pageviews: {subscription.pageview_usage.toLocaleString()}/{subscription.pageview_limit.toLocaleString()}</span>
)}
{!subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing') && subscription.current_period_end && (
<span className="block mt-1">
Renews {formatDate(new Date(subscription.current_period_end))}
</span>
)}
</p>
)}
<div className="mt-2 flex gap-2">
{subscription.has_payment_method ? (
<button onClick={() => openUnifiedSettings({ context: 'workspace', tab: 'billing' })} className="text-sm font-medium text-brand-orange hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:rounded cursor-pointer">
Manage billing
</button>
) : (
<Link href="/pricing" className="text-sm font-medium text-brand-orange hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:rounded">
Upgrade
</Link>
)}
</div>
</>
) : (
<p className="text-lg font-bold text-brand-orange">Free Plan</p>
)}
</div>
</div>
{!sitesLoading && sites.length === 0 && (
<div className="mb-8 rounded-2xl border-2 border-dashed border-brand-orange/30 bg-brand-orange/10 p-8 text-center flex flex-col items-center">
<img
@@ -393,18 +306,9 @@ export default function HomePage() {
)}
{(sitesLoading || sites.length > 0) && (
<SiteList sites={sites} siteStats={siteStats} loading={sitesLoading} onDelete={handleDelete} />
<SiteList sites={sites} siteStats={siteStats} loading={sitesLoading} />
)}
<DeleteSiteModal
open={!!deleteModalSite}
onClose={() => setDeleteModalSite(null)}
onDeleted={loadSites}
siteName={deleteModalSite?.name || ''}
siteDomain={deleteModalSite?.domain || ''}
siteId={deleteModalSite?.id || ''}
/>
<DeleteSiteModal
open={!!permanentDeleteSiteModal}
onClose={() => setPermanentDeleteSiteModal(null)}
@@ -452,6 +356,6 @@ export default function HomePage() {
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -1,9 +1,13 @@
import type { MetadataRoute } from 'next'
import { integrations } from '@/lib/integrations'
import { getIntegrationGuides } from '@/lib/integration-content'
const BASE_URL = 'https://pulse.ciphera.net'
export default function sitemap(): MetadataRoute.Sitemap {
const guides = getIntegrationGuides()
const guidesBySlug = new Map(guides.map((g) => [g.slug, g]))
const publicRoutes = [
{ url: '', priority: 1.0, changeFrequency: 'weekly' as const },
{ url: '/about', priority: 0.8, changeFrequency: 'monthly' as const },
@@ -13,20 +17,33 @@ export default function sitemap(): MetadataRoute.Sitemap {
{ url: '/changelog', priority: 0.6, changeFrequency: 'weekly' as const },
{ url: '/installation', priority: 0.8, changeFrequency: 'monthly' as const },
{ url: '/integrations', priority: 0.8, changeFrequency: 'monthly' as const },
{ url: '/integrations/script-tag', priority: 0.6, changeFrequency: 'monthly' as const },
]
const integrationRoutes = integrations.map((i) => ({
url: `/integrations/${i.id}`,
priority: 0.7,
changeFrequency: 'monthly' as const,
}))
const integrationRoutes = integrations
.filter((i) => i.dedicatedPage)
.map((i) => {
const guide = guidesBySlug.get(i.id)
return {
url: `/integrations/${i.id}`,
priority: 0.7,
changeFrequency: 'monthly' as const,
lastModified: guide?.date ? new Date(guide.date) : new Date('2026-03-28'),
}
})
const allRoutes = [...publicRoutes, ...integrationRoutes]
return allRoutes.map((route) => ({
url: `${BASE_URL}${route.url}`,
lastModified: new Date(),
changeFrequency: route.changeFrequency,
priority: route.priority,
}))
return [
...publicRoutes.map((route) => ({
url: `${BASE_URL}${route.url}`,
lastModified: new Date('2026-03-28'),
changeFrequency: route.changeFrequency,
priority: route.priority,
})),
...integrationRoutes.map((route) => ({
url: `${BASE_URL}${route.url}`,
lastModified: route.lastModified,
changeFrequency: route.changeFrequency,
priority: route.priority,
})),
]
}

View File

@@ -64,7 +64,7 @@ export default function SearchConsolePage() {
const { data: topPages, isLoading: pagesLoading } = useGSCTopPages(siteId, dateRange.start, dateRange.end, PAGE_SIZE, pagePage * PAGE_SIZE)
const { data: newQueries } = useGSCNewQueries(siteId, dateRange.start, dateRange.end)
const showSkeleton = useMinimumLoading(!gscStatus)
const showSkeleton = useMinimumLoading(!gscStatus || (gscStatus?.connected && !overview))
const fadeClass = useSkeletonFade(showSkeleton)
// Document title

View File

@@ -105,7 +105,7 @@ export default function PricingSection() {
const searchParams = useSearchParams()
const router = useRouter()
const [isYearly, setIsYearly] = useState(false)
const [sliderIndex, setSliderIndex] = useState(2) // Default to 100k (index 2)
const [sliderIndex, setSliderIndex] = useState(0) // Default to 10k (index 0)
const [loadingPlan, setLoadingPlan] = useState<string | null>(null)
const { user } = useAuth()

View File

@@ -1,14 +1,25 @@
'use client'
import { useState, useCallback, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { motion, AnimatePresence } from 'framer-motion'
import dynamic from 'next/dynamic'
import { usePathname } from 'next/navigation'
import { formatUpdatedAgo } from '@ciphera-net/ui'
import { SidebarSimple } from '@phosphor-icons/react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { formatUpdatedAgo, PlusIcon, ExternalLinkIcon, type CipheraApp } from '@ciphera-net/ui'
import { CaretDown, CaretRight, SidebarSimple } from '@phosphor-icons/react'
import { SidebarProvider, useSidebar } from '@/lib/sidebar-context'
import { useRealtime } from '@/lib/swr/dashboard'
import { getSite, listSites, type Site } from '@/lib/api/sites'
import { FAVICON_SERVICE_URL } from '@/lib/utils/favicon'
import ContentHeader from './ContentHeader'
const CIPHERA_APPS: CipheraApp[] = [
{ id: 'pulse', name: 'Pulse', description: 'Your current app — Privacy-first analytics', icon: 'https://ciphera.net/pulse_icon_no_margins.png', href: 'https://pulse.ciphera.net', isAvailable: false },
{ id: 'drop', name: 'Drop', description: 'Secure file sharing', icon: 'https://ciphera.net/drop_icon_no_margins.png', href: 'https://drop.ciphera.net', isAvailable: true },
{ id: 'auth', name: 'Auth', description: 'Your Ciphera account settings', icon: 'https://ciphera.net/auth_icon_no_margins.png', href: 'https://auth.ciphera.net', isAvailable: true },
]
const PAGE_TITLES: Record<string, string> = {
'': 'Dashboard',
journeys: 'Journeys',
@@ -28,6 +39,18 @@ function usePageTitle() {
return PAGE_TITLES[segment] ?? (segment ? segment.charAt(0).toUpperCase() + segment.slice(1) : 'Dashboard')
}
const HOME_PAGE_TITLES: Record<string, string> = {
'': 'Your Sites',
integrations: 'Integrations',
pricing: 'Pricing',
}
function useHomePageTitle() {
const pathname = usePathname()
const segment = pathname.split('/').filter(Boolean)[0] ?? ''
return HOME_PAGE_TITLES[segment] ?? (segment ? segment.charAt(0).toUpperCase() + segment.slice(1) : 'Your Sites')
}
// Load sidebar only on the client — prevents SSR flash
const Sidebar = dynamic(() => import('./Sidebar'), {
ssr: false,
@@ -41,15 +64,253 @@ const Sidebar = dynamic(() => import('./Sidebar'), {
),
})
function GlassTopBar({ siteId }: { siteId: string }) {
const { collapsed, toggle } = useSidebar()
const { data: realtime } = useRealtime(siteId)
const lastUpdatedRef = useRef<number | null>(null)
const [, setTick] = useState(0)
// ─── Breadcrumb App Switcher ───────────────────────────────
function BreadcrumbAppSwitcher() {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const [fixedPos, setFixedPos] = useState<{ left: number; top: number } | null>(null)
useEffect(() => {
if (realtime) lastUpdatedRef.current = Date.now()
}, [realtime])
const handler = (e: MouseEvent) => {
const target = e.target as Node
if (
ref.current && !ref.current.contains(target) &&
(!panelRef.current || !panelRef.current.contains(target))
) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
useEffect(() => {
if (open && buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect()
let top = rect.bottom + 4
if (panelRef.current) {
const maxTop = window.innerHeight - panelRef.current.offsetHeight - 8
top = Math.min(top, Math.max(8, maxTop))
}
setFixedPos({ left: rect.left, top })
requestAnimationFrame(() => {
if (buttonRef.current) {
const r = buttonRef.current.getBoundingClientRect()
setFixedPos({ left: r.left, top: r.bottom + 4 })
}
})
}
}, [open])
const dropdown = (
<AnimatePresence>
{open && (
<motion.div
ref={panelRef}
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
transition={{ duration: 0.15 }}
className="fixed z-50 w-72 bg-neutral-900/65 backdrop-blur-3xl backdrop-saturate-150 supports-[backdrop-filter]:bg-neutral-900/60 border border-white/[0.08] rounded-xl shadow-xl shadow-black/20 overflow-hidden origin-top-left"
style={fixedPos ? { left: fixedPos.left, top: fixedPos.top } : undefined}
>
<div className="p-4">
<div className="text-xs font-medium text-neutral-400 tracking-wider mb-3">Ciphera Apps</div>
<div className="grid grid-cols-3 gap-3">
{CIPHERA_APPS.map((app) => {
const isCurrent = app.id === 'pulse'
return (
<a
key={app.id}
href={app.href}
onClick={(e) => { if (isCurrent) { e.preventDefault(); setOpen(false) } else setOpen(false) }}
className={`group flex flex-col items-center gap-2 p-3 rounded-xl transition-all ${
isCurrent ? 'bg-neutral-800/50 cursor-default' : 'hover:bg-neutral-800/50'
}`}
>
<div className="w-10 h-10 flex items-center justify-center shrink-0">
<img src={app.icon} alt={app.name} className="w-8 h-8 object-contain" />
</div>
<span className="text-xs font-medium text-white text-center">{app.name}</span>
</a>
)
})}
</div>
<div className="h-px bg-white/[0.06] my-3" />
<a href="https://ciphera.net/products" target="_blank" rel="noopener noreferrer" className="flex items-center justify-center gap-1 text-xs text-brand-orange hover:underline">
View all products
<ExternalLinkIcon className="h-3 w-3" />
</a>
</div>
</motion.div>
)}
</AnimatePresence>
)
return (
<div className="relative" ref={ref}>
<button
ref={buttonRef}
onClick={() => setOpen(!open)}
className="inline-flex items-center gap-1 text-neutral-500 hover:text-neutral-300 transition-colors cursor-pointer"
>
<span>Pulse</span>
<CaretDown className="w-3 h-3 shrink-0 translate-y-px" />
</button>
{typeof document !== 'undefined' ? createPortal(dropdown, document.body) : dropdown}
</div>
)
}
// ─── Breadcrumb Site Picker ────────────────────────────────
function BreadcrumbSitePicker({ currentSiteId, currentSiteName }: { currentSiteId: string; currentSiteName: string }) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [sites, setSites] = useState<Site[]>([])
const ref = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const [fixedPos, setFixedPos] = useState<{ left: number; top: number } | null>(null)
const pathname = usePathname()
const router = useRouter()
useEffect(() => {
if (open && sites.length === 0) {
listSites().then(setSites).catch(() => {})
}
}, [open, sites.length])
const updatePosition = useCallback(() => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect()
let top = rect.bottom + 4
if (panelRef.current) {
const maxTop = window.innerHeight - panelRef.current.offsetHeight - 8
top = Math.min(top, Math.max(8, maxTop))
}
setFixedPos({ left: rect.left, top })
}
}, [])
useEffect(() => {
const handler = (e: MouseEvent) => {
const target = e.target as Node
if (
ref.current && !ref.current.contains(target) &&
(!panelRef.current || !panelRef.current.contains(target))
) {
if (open) { setOpen(false); setSearch('') }
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
useEffect(() => {
if (open) {
updatePosition()
requestAnimationFrame(() => updatePosition())
}
}, [open, updatePosition])
const closePicker = () => { setOpen(false); setSearch('') }
const switchSite = (id: string) => {
router.push(`/sites/${id}${pathname.replace(/^\/sites\/[^/]+/, '')}`)
closePicker()
}
const filtered = sites.filter(
(s) => s.name.toLowerCase().includes(search.toLowerCase()) || s.domain.toLowerCase().includes(search.toLowerCase())
)
const dropdown = (
<AnimatePresence>
{open && (
<motion.div
ref={panelRef}
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
transition={{ duration: 0.15 }}
className="fixed z-50 w-[240px] bg-neutral-900/65 backdrop-blur-3xl backdrop-saturate-150 supports-[backdrop-filter]:bg-neutral-900/60 border border-white/[0.08] rounded-xl shadow-xl shadow-black/20 overflow-hidden origin-top-left"
style={fixedPos ? { left: fixedPos.left, top: fixedPos.top } : undefined}
>
<div className="p-2">
<input
type="text"
placeholder="Search sites..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Escape') closePicker() }}
className="w-full px-3 py-1.5 text-sm bg-white/[0.04] border border-white/[0.08] rounded-lg outline-none focus:ring-2 focus:ring-brand-orange/40 text-white placeholder:text-neutral-400"
autoFocus
/>
</div>
<div className="max-h-64 overflow-y-auto">
{filtered.map((site) => (
<button
key={site.id}
onClick={() => switchSite(site.id)}
className={`w-full flex items-center gap-2.5 px-4 py-2 text-sm text-left ${
site.id === currentSiteId
? 'bg-brand-orange/10 text-brand-orange font-medium'
: 'text-neutral-300 hover:bg-white/[0.06]'
}`}
>
<img
src={`${FAVICON_SERVICE_URL}?domain=${site.domain}&sz=64`}
alt=""
className="w-5 h-5 rounded object-contain shrink-0"
/>
<span className="flex flex-col min-w-0">
<span className="truncate">{site.name}</span>
<span className="text-xs text-neutral-400 truncate">{site.domain}</span>
</span>
</button>
))}
{filtered.length === 0 && <p className="px-4 py-3 text-sm text-neutral-400">No sites found</p>}
</div>
<div className="border-t border-white/[0.06] p-2">
<Link href="/sites/new" onClick={() => closePicker()} className="flex items-center gap-2 px-3 py-1.5 text-sm text-brand-orange hover:bg-white/[0.06] rounded-lg">
<PlusIcon className="w-4 h-4" />
Add new site
</Link>
</div>
</motion.div>
)}
</AnimatePresence>
)
return (
<div className="relative" ref={ref}>
<button
ref={buttonRef}
onClick={() => setOpen(!open)}
className="inline-flex items-center gap-1 text-neutral-500 hover:text-neutral-300 transition-colors max-w-[160px] cursor-pointer"
>
<span className="truncate">{currentSiteName}</span>
<CaretDown className="w-3 h-3 shrink-0 translate-y-px" />
</button>
{typeof document !== 'undefined' ? createPortal(dropdown, document.body) : dropdown}
</div>
)
}
// ─── Glass Top Bar ─────────────────────────────────────────
function GlassTopBar({ siteId }: { siteId: string | null }) {
const { collapsed, toggle } = useSidebar()
const { data: realtime } = useRealtime(siteId ?? '')
const lastUpdatedRef = useRef<number | null>(null)
const [, setTick] = useState(0)
const [siteName, setSiteName] = useState<string | null>(null)
useEffect(() => {
if (siteId && realtime) lastUpdatedRef.current = Date.now()
}, [siteId, realtime])
useEffect(() => {
if (lastUpdatedRef.current == null) return
@@ -57,11 +318,18 @@ function GlassTopBar({ siteId }: { siteId: string }) {
return () => clearInterval(timer)
}, [realtime])
const pageTitle = usePageTitle()
useEffect(() => {
if (!siteId) { setSiteName(null); return }
getSite(siteId).then((s) => setSiteName(s.name)).catch(() => {})
}, [siteId])
const dashboardTitle = usePageTitle()
const homeTitle = useHomePageTitle()
const pageTitle = siteId ? dashboardTitle : homeTitle
return (
<div className="hidden md:flex items-center justify-between shrink-0 px-3 pt-1.5 pb-1">
{/* Left: collapse toggle + page title */}
{/* Left: collapse toggle + breadcrumbs */}
<div className="flex items-center gap-1.5">
<button
onClick={toggle}
@@ -70,11 +338,25 @@ function GlassTopBar({ siteId }: { siteId: string }) {
>
<SidebarSimple className="w-[18px] h-[18px]" weight={collapsed ? 'regular' : 'fill'} />
</button>
<span className="text-sm text-neutral-400 font-medium">{pageTitle}</span>
<nav className="flex items-center gap-1 text-sm font-medium">
<BreadcrumbAppSwitcher />
<CaretRight className="w-3 h-3 text-neutral-600" />
{siteId && siteName ? (
<>
<Link href="/" className="text-neutral-500 hover:text-neutral-300 transition-colors">Your Sites</Link>
<CaretRight className="w-3 h-3 text-neutral-600" />
<BreadcrumbSitePicker currentSiteId={siteId} currentSiteName={siteName} />
<CaretRight className="w-3 h-3 text-neutral-600" />
<span className="text-neutral-400">{pageTitle}</span>
</>
) : (
<span className="text-neutral-400">{pageTitle}</span>
)}
</nav>
</div>
{/* Realtime indicator */}
{lastUpdatedRef.current != null && (
{siteId && lastUpdatedRef.current != null && (
<div className="flex items-center gap-1.5 text-xs text-neutral-500">
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75" />
@@ -91,7 +373,7 @@ export default function DashboardShell({
siteId,
children,
}: {
siteId: string
siteId: string | null
children: React.ReactNode
}) {
const [mobileOpen, setMobileOpen] = useState(false)

View File

@@ -2,7 +2,6 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { motion, AnimatePresence } from 'framer-motion'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { listSites, type Site } from '@/lib/api/sites'
@@ -14,7 +13,7 @@ import { getUserOrganizations, switchContext, type OrganizationMember } from '@/
import { setSessionAction } from '@/app/actions/auth'
import { logger } from '@/lib/utils/logger'
import { FAVICON_SERVICE_URL } from '@/lib/utils/favicon'
import { Gauge as GaugeIcon } from '@phosphor-icons/react'
import { Gauge as GaugeIcon, Plugs as PlugsIcon, Tag as TagIcon } from '@phosphor-icons/react'
import {
LayoutDashboardIcon,
PathIcon,
@@ -24,42 +23,13 @@ import {
CloudUploadIcon,
HeartbeatIcon,
SettingsIcon,
ChevronUpDownIcon,
PlusIcon,
XIcon,
AppLauncher,
BookOpenIcon,
UserMenu,
type CipheraApp,
} from '@ciphera-net/ui'
import NotificationCenter from '@/components/notifications/NotificationCenter'
const CIPHERA_APPS: CipheraApp[] = [
{
id: 'pulse',
name: 'Pulse',
description: 'Your current app — Privacy-first analytics',
icon: 'https://ciphera.net/pulse_icon_no_margins.png',
href: 'https://pulse.ciphera.net',
isAvailable: false,
},
{
id: 'drop',
name: 'Drop',
description: 'Secure file sharing',
icon: 'https://ciphera.net/drop_icon_no_margins.png',
href: 'https://drop.ciphera.net',
isAvailable: true,
},
{
id: 'auth',
name: 'Auth',
description: 'Your Ciphera account settings',
icon: 'https://ciphera.net/auth_icon_no_margins.png',
href: 'https://auth.ciphera.net',
isAvailable: true,
},
]
const EXPANDED = 256
const COLLAPSED = 64
@@ -111,179 +81,41 @@ function Label({ children, collapsed }: { children: React.ReactNode; collapsed:
)
}
// ─── Site Picker ────────────────────────────────────────────
// ─── Sidebar Tooltip (portal-based, escapes overflow-hidden) ──
function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollapsed, pickerOpenCallback }: {
sites: Site[]; siteId: string; collapsed: boolean
onExpand: () => void; onCollapse: () => void; wasCollapsed: React.MutableRefObject<boolean>
pickerOpenCallback: React.MutableRefObject<(() => void) | null>
}) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [faviconFailed, setFaviconFailed] = useState(false)
const [faviconLoaded, setFaviconLoaded] = useState(false)
function SidebarTooltip({ children, label }: { children: React.ReactNode; label: string }) {
const [show, setShow] = useState(false)
const [pos, setPos] = useState({ x: 0, y: 0 })
const ref = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const [fixedPos, setFixedPos] = useState<{ left: number; top: number } | null>(null)
const pathname = usePathname()
const router = useRouter()
const currentSite = sites.find((s) => s.id === siteId)
const faviconUrl = currentSite?.domain ? `${FAVICON_SERVICE_URL}?domain=${currentSite.domain}&sz=64` : null
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const updatePosition = useCallback(() => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect()
if (collapsed) {
// Collapsed: open to the right, like AppLauncher/UserMenu/Notifications
let top = rect.top
if (panelRef.current) {
const maxTop = window.innerHeight - panelRef.current.offsetHeight - 8
top = Math.min(top, Math.max(8, maxTop))
}
setFixedPos({ left: rect.right + 8, top })
} else {
// Expanded: open below the button
let top = rect.bottom + 4
if (panelRef.current) {
const maxTop = window.innerHeight - panelRef.current.offsetHeight - 8
top = Math.min(top, Math.max(8, maxTop))
}
setFixedPos({ left: rect.left, top })
const handleEnter = () => {
timerRef.current = setTimeout(() => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect()
setPos({ x: rect.right + 8, y: rect.top + rect.height / 2 })
setShow(true)
}
}
}, [collapsed])
useEffect(() => {
const handler = (e: MouseEvent) => {
const target = e.target as Node
if (
ref.current && !ref.current.contains(target) &&
(!panelRef.current || !panelRef.current.contains(target))
) {
if (open) {
setOpen(false); setSearch('')
}
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open, onCollapse, wasCollapsed])
useEffect(() => {
if (open) {
updatePosition()
requestAnimationFrame(() => updatePosition())
}
}, [open, updatePosition])
const closePicker = () => {
setOpen(false); setSearch('')
}, 100)
}
const switchSite = (id: string) => {
router.push(`/sites/${id}${pathname.replace(/^\/sites\/[^/]+/, '')}`)
closePicker()
const handleLeave = () => {
clearTimeout(timerRef.current)
setShow(false)
}
const filtered = sites.filter(
(s) => s.name.toLowerCase().includes(search.toLowerCase()) || s.domain.toLowerCase().includes(search.toLowerCase())
)
const dropdown = (
<AnimatePresence>
{open && (
<motion.div
ref={panelRef}
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
transition={{ duration: 0.15 }}
className="fixed z-50 w-[240px] bg-neutral-900/65 backdrop-blur-3xl backdrop-saturate-150 supports-[backdrop-filter]:bg-neutral-900/60 border border-white/[0.08] rounded-xl shadow-xl shadow-black/20 overflow-hidden origin-top-left"
style={fixedPos ? { left: fixedPos.left, top: fixedPos.top } : undefined}
>
<div className="p-2">
<input
type="text"
placeholder="Search sites..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') closePicker()
}}
className="w-full px-3 py-1.5 text-sm bg-white/[0.04] border border-white/[0.08] rounded-lg outline-none focus:ring-2 focus:ring-brand-orange/40 text-white placeholder:text-neutral-400"
autoFocus
/>
</div>
<div className="max-h-64 overflow-y-auto">
{filtered.map((site) => (
<button
key={site.id}
onClick={() => switchSite(site.id)}
className={`w-full flex items-center gap-2.5 px-4 py-2 text-sm text-left ${
site.id === siteId
? 'bg-brand-orange/10 text-brand-orange font-medium'
: 'text-neutral-300 hover:bg-white/[0.06]'
}`}
>
<img
src={`${FAVICON_SERVICE_URL}?domain=${site.domain}&sz=64`}
alt=""
className="w-5 h-5 rounded object-contain shrink-0"
/>
<span className="flex flex-col min-w-0">
<span className="truncate">{site.name}</span>
<span className="text-xs text-neutral-400 truncate">{site.domain}</span>
</span>
</button>
))}
{filtered.length === 0 && <p className="px-4 py-3 text-sm text-neutral-400">No sites found</p>}
</div>
<div className="border-t border-white/[0.06] p-2">
<Link href="/sites/new" onClick={() => closePicker()} className="flex items-center gap-2 px-3 py-1.5 text-sm text-brand-orange hover:bg-white/[0.06] rounded-lg">
<PlusIcon className="w-4 h-4" />
Add new site
</Link>
</div>
</motion.div>
)}
</AnimatePresence>
)
return (
<div className="relative mb-4 px-2" ref={ref}>
<button
ref={buttonRef}
onClick={() => setOpen(!open)}
className="w-full flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-200 hover:bg-white/[0.06] overflow-hidden"
>
<span className="w-7 h-7 rounded-md bg-brand-orange/10 flex items-center justify-center shrink-0 overflow-hidden">
{faviconUrl && !faviconFailed ? (
<>
{!faviconLoaded && <span className="w-5 h-5 rounded animate-pulse bg-neutral-800" />}
<img
src={faviconUrl}
alt=""
className={`w-5 h-5 object-contain ${faviconLoaded ? '' : 'hidden'}`}
onLoad={() => setFaviconLoaded(true)}
onError={() => setFaviconFailed(true)}
/>
</>
) : (
<span className="text-xs font-bold text-brand-orange">
{currentSite?.name?.charAt(0).toUpperCase() || '?'}
</span>
)}
</span>
<Label collapsed={collapsed}>
<span className="flex items-center gap-1">
<span className="truncate">{currentSite?.name || ''}</span>
<ChevronUpDownIcon className="w-4 h-4 text-neutral-400 shrink-0" />
</span>
</Label>
</button>
{typeof document !== 'undefined' ? createPortal(dropdown, document.body) : dropdown}
<div ref={ref} onMouseEnter={handleEnter} onMouseLeave={handleLeave}>
{children}
{show && typeof document !== 'undefined' && createPortal(
<span
className="fixed z-[100] px-3 py-2 rounded-lg bg-neutral-950 border border-neutral-800/60 text-white text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg shadow-black/20 -translate-y-1/2"
style={{ left: pos.x, top: pos.y }}
>
{label}
</span>,
document.body
)}
</div>
)
}
@@ -302,61 +134,122 @@ function NavLink({
const matchesPending = pendingHref !== null && (item.matchPrefix ? pendingHref.startsWith(href) : pendingHref === href)
const active = matchesPathname || matchesPending
return (
<div className="relative group/nav">
<Link
href={href}
onClick={() => { onNavigate(href); onClick?.() }}
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 ${
active
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-400 hover:text-white hover:bg-white/[0.06] hover:translate-x-0.5'
}`}
>
<span className="w-7 h-7 flex items-center justify-center shrink-0">
<item.icon className="w-[18px] h-[18px]" weight={active ? 'fill' : 'regular'} />
</span>
<Label collapsed={collapsed}>{item.label}</Label>
</Link>
{collapsed && (
<span className="pointer-events-none absolute left-full top-1/2 -translate-y-1/2 ml-2 px-2 py-1 rounded-md bg-neutral-800 text-white text-xs whitespace-nowrap opacity-0 group-hover/nav:opacity-100 transition-opacity duration-150 delay-150 z-50">
{item.label}
</span>
)}
</div>
const link = (
<Link
href={href}
onClick={() => { onNavigate(href); onClick?.() }}
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 ${
active
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-400 hover:text-white hover:bg-white/[0.06] hover:translate-x-0.5'
}`}
>
<span className="w-7 h-7 flex items-center justify-center shrink-0">
<item.icon className="w-[18px] h-[18px]" weight={active ? 'fill' : 'regular'} />
</span>
<Label collapsed={collapsed}>{item.label}</Label>
</Link>
)
if (collapsed) return <SidebarTooltip label={item.label}>{link}</SidebarTooltip>
return link
}
// ─── Settings Button (opens unified modal instead of navigating) ─────
function SettingsButton({
item, collapsed, onClick,
item, collapsed, onClick, settingsContext = 'site',
}: {
item: NavItem; collapsed: boolean; onClick?: () => void
item: NavItem; collapsed: boolean; onClick?: () => void; settingsContext?: 'site' | 'workspace'
}) {
const { openUnifiedSettings } = useUnifiedSettings()
return (
<div className="relative group/nav">
<button
onClick={() => {
openUnifiedSettings({ context: 'site', tab: 'general' })
onClick?.()
}}
className="flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 text-neutral-400 hover:text-white hover:bg-white/[0.06] hover:translate-x-0.5 w-full cursor-pointer"
>
<span className="w-7 h-7 flex items-center justify-center shrink-0">
<item.icon className="w-[18px] h-[18px]" weight="regular" />
</span>
<Label collapsed={collapsed}>{item.label}</Label>
</button>
{collapsed && (
<span className="pointer-events-none absolute left-full top-1/2 -translate-y-1/2 ml-2 px-2 py-1 rounded-md bg-neutral-800 text-white text-xs whitespace-nowrap opacity-0 group-hover/nav:opacity-100 transition-opacity duration-150 delay-150 z-50">
{item.label}
</span>
)}
</div>
const btn = (
<button
onClick={() => {
openUnifiedSettings({ context: settingsContext, tab: 'general' })
onClick?.()
}}
className="flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 text-neutral-400 hover:text-white hover:bg-white/[0.06] hover:translate-x-0.5 w-full cursor-pointer"
>
<span className="w-7 h-7 flex items-center justify-center shrink-0">
<item.icon className="w-[18px] h-[18px]" weight="regular" />
</span>
<Label collapsed={collapsed}>{item.label}</Label>
</button>
)
if (collapsed) return <SidebarTooltip label={item.label}>{btn}</SidebarTooltip>
return btn
}
// ─── Home Nav Link (static href, no siteId) ───────────────
function HomeNavLink({
href, icon: Icon, label, collapsed, onClick, external,
}: {
href: string; icon: React.ComponentType<{ className?: string; weight?: IconWeight }>
label: string; collapsed: boolean; onClick?: () => void; external?: boolean
}) {
const pathname = usePathname()
const active = !external && pathname === href
const link = (
<Link
href={href}
onClick={onClick}
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 ${
active
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-400 hover:text-white hover:bg-white/[0.06] hover:translate-x-0.5'
}`}
>
<span className="w-7 h-7 flex items-center justify-center shrink-0">
<Icon className="w-[18px] h-[18px]" weight={active ? 'fill' : 'regular'} />
</span>
<Label collapsed={collapsed}>{label}</Label>
</Link>
)
if (collapsed) return <SidebarTooltip label={label}>{link}</SidebarTooltip>
return link
}
// ─── Home Site Link (favicon + name) ───────────────────────
function HomeSiteLink({
site, collapsed, onClick,
}: {
site: Site; collapsed: boolean; onClick?: () => void
}) {
const pathname = usePathname()
const href = `/sites/${site.id}`
const active = pathname.startsWith(href)
const link = (
<Link
href={href}
onClick={onClick}
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 ${
active
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-400 hover:text-white hover:bg-white/[0.06] hover:translate-x-0.5'
}`}
>
<span className="w-7 h-7 rounded-md bg-white/[0.04] flex items-center justify-center shrink-0 overflow-hidden">
<img
src={`${FAVICON_SERVICE_URL}?domain=${site.domain}&sz=64`}
alt=""
className="w-[18px] h-[18px] rounded object-contain"
/>
</span>
<Label collapsed={collapsed}>{site.name}</Label>
</Link>
)
if (collapsed) return <SidebarTooltip label={site.name}>{link}</SidebarTooltip>
return link
}
// ─── Sidebar Content ────────────────────────────────────────
@@ -364,17 +257,13 @@ function SettingsButton({
interface SidebarContentProps {
isMobile: boolean
collapsed: boolean
siteId: string
siteId: string | null
sites: Site[]
canEdit: boolean
pendingHref: string | null
onNavigate: (href: string) => void
onMobileClose: () => void
onExpand: () => void
onCollapse: () => void
onToggle: () => void
wasCollapsed: React.MutableRefObject<boolean>
pickerOpenCallbackRef: React.MutableRefObject<(() => void) | null>
auth: ReturnType<typeof useAuth>
orgs: OrganizationMember[]
onSwitchOrganization: (orgId: string | null) => Promise<void>
@@ -384,8 +273,8 @@ interface SidebarContentProps {
function SidebarContent({
isMobile, collapsed, siteId, sites, canEdit, pendingHref,
onNavigate, onMobileClose, onExpand, onCollapse, onToggle,
wasCollapsed, pickerOpenCallbackRef, auth, orgs, onSwitchOrganization, openSettings, openOrgSettings,
onNavigate, onMobileClose, onToggle,
auth, orgs, onSwitchOrganization, openSettings, openOrgSettings,
}: SidebarContentProps) {
const router = useRouter()
const c = isMobile ? false : collapsed
@@ -393,16 +282,6 @@ function SidebarContent({
return (
<div className="flex flex-col h-full overflow-hidden">
{/* App Switcher — top of sidebar (scope-level switch) */}
<div className="flex items-center gap-2.5 px-[14px] pt-1.5 pb-1 shrink-0 overflow-hidden">
<span className="w-9 h-9 flex items-center justify-center shrink-0">
<AppLauncher apps={CIPHERA_APPS} currentAppId="pulse" anchor="right" />
</span>
<Label collapsed={c}>
<span className="text-xs font-medium text-neutral-400 dark:text-neutral-500 uppercase tracking-wider">Ciphera</span>
</Label>
</div>
{/* Logo — fixed layout, text fades */}
<Link href="/" className="flex items-center gap-3 px-[14px] py-4 shrink-0 group overflow-hidden">
<span className="w-9 h-9 flex items-center justify-center shrink-0">
@@ -413,49 +292,122 @@ function SidebarContent({
</span>
</Link>
{/* Site Picker */}
<SitePicker sites={sites} siteId={siteId} collapsed={c} onExpand={onExpand} onCollapse={onCollapse} wasCollapsed={wasCollapsed} pickerOpenCallback={pickerOpenCallbackRef} />
{/* Nav Groups */}
<nav className="flex-1 overflow-y-auto overflow-x-hidden px-2 space-y-4">
{NAV_GROUPS.map((group) => (
<div key={group.label}>
{siteId ? (
<nav className="flex-1 overflow-y-auto overflow-x-hidden px-2 space-y-4">
{NAV_GROUPS.map((group) => (
<div key={group.label}>
<div className="h-5 flex items-center overflow-hidden">
{c ? (
<div className="mx-1 w-full border-t border-white/[0.04]" />
) : (
<p className="px-2.5 text-[11px] font-semibold text-neutral-400 dark:text-neutral-500 uppercase tracking-wider whitespace-nowrap">
{group.label}
</p>
)}
</div>
<div className="space-y-0.5">
{group.items.map((item) => (
<NavLink key={item.label} item={item} siteId={siteId} collapsed={c} onClick={isMobile ? onMobileClose : undefined} pendingHref={pendingHref} onNavigate={onNavigate} />
))}
{group.label === 'Infrastructure' && canEdit && (
<SettingsButton item={SETTINGS_ITEM} collapsed={c} onClick={isMobile ? onMobileClose : undefined} />
)}
</div>
</div>
))}
</nav>
) : (
<nav className="flex-1 overflow-y-auto overflow-x-hidden px-2 space-y-4">
{/* Your Sites */}
<div>
{c ? (
<div className="mx-3 my-2 border-t border-white/[0.04]" />
) : (
<div className="h-5 flex items-center overflow-hidden">
<p className="px-2.5 text-[11px] font-semibold text-neutral-400 dark:text-neutral-500 uppercase tracking-wider whitespace-nowrap">
{group.label}
Your Sites
</p>
</div>
)}
<div className="space-y-0.5">
{group.items.map((item) => (
<NavLink key={item.label} item={item} siteId={siteId} collapsed={c} onClick={isMobile ? onMobileClose : undefined} pendingHref={pendingHref} onNavigate={onNavigate} />
{sites.map((site) => (
<HomeSiteLink key={site.id} site={site} collapsed={c} onClick={isMobile ? onMobileClose : undefined} />
))}
{group.label === 'Infrastructure' && canEdit && (
<SettingsButton item={SETTINGS_ITEM} collapsed={c} onClick={isMobile ? onMobileClose : undefined} />
)}
<HomeNavLink href="/sites/new" icon={PlusIcon} label="Add New Site" collapsed={c} onClick={isMobile ? onMobileClose : undefined} />
</div>
</div>
))}
</nav>
{/* Workspace */}
<div>
{c ? (
<div className="mx-3 my-2 border-t border-white/[0.04]" />
) : (
<div className="h-5 flex items-center overflow-hidden">
<p className="px-2.5 text-[11px] font-semibold text-neutral-400 dark:text-neutral-500 uppercase tracking-wider whitespace-nowrap">
Workspace
</p>
</div>
)}
<div className="space-y-0.5">
<HomeNavLink href="/integrations" icon={PlugsIcon} label="Integrations" collapsed={c} onClick={isMobile ? onMobileClose : undefined} />
<HomeNavLink href="/pricing" icon={TagIcon} label="Pricing" collapsed={c} onClick={isMobile ? onMobileClose : undefined} />
<SettingsButton item={{ label: 'Workspace Settings', href: () => '', icon: SettingsIcon, matchPrefix: false }} collapsed={c} onClick={isMobile ? onMobileClose : undefined} settingsContext="workspace" />
</div>
</div>
{/* Resources */}
<div>
{c ? (
<div className="mx-3 my-2 border-t border-white/[0.04]" />
) : (
<div className="h-5 flex items-center overflow-hidden">
<p className="px-2.5 text-[11px] font-semibold text-neutral-400 dark:text-neutral-500 uppercase tracking-wider whitespace-nowrap">
Resources
</p>
</div>
)}
<div className="space-y-0.5">
<HomeNavLink href="https://docs.ciphera.net" icon={BookOpenIcon} label="Documentation" collapsed={c} onClick={isMobile ? onMobileClose : undefined} external />
</div>
</div>
</nav>
)}
{/* Bottom — utility items */}
<div className="border-t border-white/[0.06] px-2 py-3 shrink-0">
{/* Notifications, Profile — same layout as nav items */}
<div className="space-y-0.5 mb-1">
<div className="relative group/notif">
{c ? (
<SidebarTooltip label="Notifications">
<NotificationCenter anchor="right" variant="sidebar">
<Label collapsed={c}>Notifications</Label>
</NotificationCenter>
</SidebarTooltip>
) : (
<NotificationCenter anchor="right" variant="sidebar">
<Label collapsed={c}>Notifications</Label>
</NotificationCenter>
{c && (
<span className="pointer-events-none absolute left-full top-1/2 -translate-y-1/2 ml-2 px-2 py-1 rounded-md bg-neutral-800 text-white text-xs whitespace-nowrap opacity-0 group-hover/notif:opacity-100 transition-opacity duration-150 delay-150 z-50">
Notifications
</span>
)}
</div>
<div className="relative group/user">
)}
{c ? (
<SidebarTooltip label={user?.display_name?.trim() || 'Profile'}>
<UserMenu
auth={auth}
LinkComponent={Link}
orgs={orgs}
activeOrgId={auth.user?.org_id}
onSwitchOrganization={onSwitchOrganization}
onCreateOrganization={() => router.push('/onboarding')}
allowPersonalOrganization={false}
onOpenSettings={openSettings}
onOpenOrgSettings={openOrgSettings}
compact
anchor="right"
>
<Label collapsed={c}>{user?.display_name?.trim() || 'Profile'}</Label>
</UserMenu>
</SidebarTooltip>
) : (
<UserMenu
auth={auth}
LinkComponent={Link}
@@ -471,12 +423,7 @@ function SidebarContent({
>
<Label collapsed={c}>{user?.display_name?.trim() || 'Profile'}</Label>
</UserMenu>
{c && (
<span className="pointer-events-none absolute left-full top-1/2 -translate-y-1/2 ml-2 px-2 py-1 rounded-md bg-neutral-800 text-white text-xs whitespace-nowrap opacity-0 group-hover/user:opacity-100 transition-opacity duration-150 delay-150 z-50">
{user?.display_name?.trim() || 'Profile'}
</span>
)}
</div>
)}
</div>
</div>
</div>
@@ -488,7 +435,7 @@ function SidebarContent({
export default function Sidebar({
siteId, mobileOpen, onMobileClose, onMobileOpen,
}: {
siteId: string; mobileOpen: boolean; onMobileClose: () => void; onMobileOpen: () => void
siteId: string | null; mobileOpen: boolean; onMobileClose: () => void; onMobileOpen: () => void
}) {
const auth = useAuth()
const { user } = auth
@@ -500,9 +447,7 @@ export default function Sidebar({
const [orgs, setOrgs] = useState<OrganizationMember[]>([])
const [pendingHref, setPendingHref] = useState<string | null>(null)
const [mobileClosing, setMobileClosing] = useState(false)
const wasCollapsedRef = useRef(false)
const pickerOpenCallbackRef = useRef<(() => void) | null>(null)
const { collapsed, toggle, expand, collapse } = useSidebar()
const { collapsed, toggle } = useSidebar()
useEffect(() => { listSites().then(setSites).catch(() => {}) }, [])
useEffect(() => {
@@ -542,12 +487,6 @@ export default function Sidebar({
<aside
className="hidden md:flex flex-col shrink-0 bg-transparent overflow-hidden relative z-10"
style={{ width: collapsed ? COLLAPSED : EXPANDED, transition: 'width 200ms cubic-bezier(0.4, 0, 0.2, 1)' }}
onTransitionEnd={(e) => {
if (e.propertyName === 'width' && pickerOpenCallbackRef.current) {
pickerOpenCallbackRef.current()
pickerOpenCallbackRef.current = null
}
}}
>
<SidebarContent
isMobile={false}
@@ -558,11 +497,7 @@ export default function Sidebar({
pendingHref={pendingHref}
onNavigate={handleNavigate}
onMobileClose={onMobileClose}
onExpand={expand}
onCollapse={collapse}
onToggle={toggle}
wasCollapsed={wasCollapsedRef}
pickerOpenCallbackRef={pickerOpenCallbackRef}
auth={auth}
orgs={orgs}
onSwitchOrganization={handleSwitchOrganization}
@@ -602,11 +537,7 @@ export default function Sidebar({
pendingHref={pendingHref}
onNavigate={handleNavigate}
onMobileClose={handleMobileClose}
onExpand={expand}
onCollapse={collapse}
onToggle={toggle}
wasCollapsed={wasCollapsedRef}
pickerOpenCallbackRef={pickerOpenCallbackRef}
auth={auth}
orgs={orgs}
onSwitchOrganization={handleSwitchOrganization}

View File

@@ -303,7 +303,11 @@ export default function UnifiedSettingsModal() {
useEffect(() => {
if (!isOpen || !user?.org_id) return
if (typeof window !== 'undefined') {
if (initTab?.siteId) {
// Site ID passed explicitly (e.g. from home page site card)
setActiveSiteId(initTab.siteId)
if (!initTab?.context) setContext('site')
} else if (typeof window !== 'undefined') {
const match = window.location.pathname.match(/\/sites\/([a-f0-9-]+)/)
if (match) {
setActiveSiteId(match[1])

View File

@@ -6,7 +6,7 @@ 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 { useUnifiedSettings } from '@/lib/unified-settings-context'
import { FAVICON_SERVICE_URL } from '@/lib/utils/favicon'
export type SiteStatsMap = Record<string, { stats: Stats }>
@@ -15,18 +15,16 @@ interface SiteListProps {
sites: Site[]
siteStats: SiteStatsMap
loading: boolean
onDelete: (id: string) => void
}
interface SiteCardProps {
site: Site
stats: Stats | null
statsLoading: boolean
onDelete: (id: string) => void
canDelete: boolean
}
function SiteCard({ site, stats, statsLoading, onDelete, canDelete }: SiteCardProps) {
function SiteCard({ site, stats, statsLoading }: SiteCardProps) {
const { openUnifiedSettings } = useUnifiedSettings()
const visitors24h = stats?.visitors ?? 0
const pageviews = stats?.pageviews ?? 0
@@ -104,24 +102,20 @@ function SiteCard({ site, stats, statsLoading, onDelete, canDelete }: SiteCardPr
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-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"
title="Delete Site"
>
<SettingsIcon className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={() => openUnifiedSettings({ context: 'site', tab: 'general', siteId: 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-neutral-300 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 cursor-pointer"
title="Site Settings"
>
<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'
export default function SiteList({ sites, siteStats, loading }: SiteListProps) {
if (loading) {
return (
@@ -165,8 +159,6 @@ export default function SiteList({ sites, siteStats, loading, onDelete }: SiteLi
site={site}
stats={data?.stats ?? null}
statsLoading={!data}
onDelete={onDelete}
canDelete={canDelete}
/>
)
})}

View File

@@ -0,0 +1,39 @@
---
title: "Angular"
description: "Add Pulse analytics to your Angular application. Simple index.html setup for all Angular versions."
category: "framework"
brandColor: "#0F0F11"
officialUrl: "https://angular.dev"
relatedIds: ["react", "vue"]
date: "2026-03-28"
---
Add the script to your `src/index.html` — the single entry point for all Angular applications.
---
## Add the Pulse script to index.html
Place the Pulse script inside the `<head>` tag of your Angular app's `src/index.html`.
<CodeBlock filename="src/index.html">{`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>
<title>My Angular App</title>
<base href="/">
</head>
<body>
<app-root></app-root>
</body>
</html>`}</CodeBlock>
For more details, see the [Angular docs](https://angular.dev/guide/components).

View File

@@ -0,0 +1,48 @@
---
title: "Astro"
description: "Integrate Pulse analytics with Astro. Add the script to your base layout for all pages."
category: "framework"
brandColor: "#BC52EE"
officialUrl: "https://docs.astro.build"
relatedIds: ["svelte", "hugo", "eleventy"]
date: "2026-03-28"
---
Add the Pulse script to your base layout so it's included on every page of your Astro site.
---
## Add to your base layout
Place the Pulse script in the `<head>` of your shared layout component.
<CodeBlock filename="src/layouts/BaseLayout.astro">{`---
interface Props {
title: string
}
const { title } = Astro.props
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>
<title>{title}</title>
</head>
<body>
<slot />
</body>
</html>`}</CodeBlock>
If you're using Astro's View Transitions, the script will persist across navigations by default since it's in the `<head>`.
For more details, see the [Astro scripts docs](https://docs.astro.build/en/guides/client-side-scripts/).

View File

@@ -0,0 +1,42 @@
---
title: "Django"
description: "Add Pulse analytics to your Django app. Template-based integration for all Django versions."
category: "backend"
brandColor: "#092E20"
officialUrl: "https://docs.djangoproject.com"
relatedIds: ["flask", "laravel", "htmx"]
date: "2026-03-28"
---
Add the Pulse script to your base template with a debug guard.
---
## Add to your base template
Use Django's template tags to only load the script when `DEBUG` is `False`.
<CodeBlock filename="templates/base.html">{`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% if not debug %}
<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>
{% endif %}
<title>{% block title %}My Django App{% endblock %}</title>
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>`}</CodeBlock>
Make sure to pass `debug` to the template context via `settings.DEBUG`, or use a context processor to make it available globally.
For more details, see the [Django template docs](https://docs.djangoproject.com/en/stable/ref/templates/builtins/).

View File

@@ -0,0 +1,45 @@
---
title: "Drupal"
description: "Add Pulse analytics to your Drupal site using a module or theme template."
category: "cms"
brandColor: "#0678BE"
officialUrl: "https://www.drupal.org/docs"
relatedIds: ["wordpress", "joomla"]
date: "2026-03-28"
---
Add the Pulse script via a contributed module or by editing your theme's Twig template.
---
## Method 1: Using Asset Injector module
Install the [Asset Injector](https://www.drupal.org/project/asset_injector) module and create a new JS injector with the Pulse script. Set it to load on all pages in the header region.
## Method 2: Edit html.html.twig
Add the script directly to your theme's `html.html.twig` template in the head area.
<CodeBlock filename="templates/html.html.twig">{`<!DOCTYPE html>
<html{{ html_attributes }}>
<head>
<head-placeholder token="{{ placeholder_token }}">
<title>{{ head_title|safe_join(' | ') }}</title>
<css-placeholder token="{{ placeholder_token }}">
<js-placeholder token="{{ placeholder_token }}">
<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>
</head>
<body{{ attributes }}>
{{ page_top }}
{{ page }}
{{ page_bottom }}
<js-bottom-placeholder token="{{ placeholder_token }}">
</body>
</html>`}</CodeBlock>
For more details, see the [Drupal theming docs](https://www.drupal.org/docs/theming-drupal).

View File

@@ -0,0 +1,38 @@
---
title: "Eleventy"
description: "Add Pulse analytics to your Eleventy (11ty) site. Template-based integration."
category: "ssg"
brandColor: "#222222"
officialUrl: "https://www.11ty.dev/docs"
relatedIds: ["hugo", "jekyll", "astro"]
date: "2026-03-28"
---
Add the Pulse script to your base Nunjucks or Liquid layout.
---
## Add to your base layout
Place the Pulse script inside the `<head>` of your shared base template.
<CodeBlock filename="src/_includes/base.njk">{`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>
<title>{{ title }}</title>
</head>
<body>
{{ content | safe }}
</body>
</html>`}</CodeBlock>
For more details, see the [Eleventy layouts docs](https://www.11ty.dev/docs/layouts/).

View File

@@ -0,0 +1,61 @@
---
title: "Express"
description: "Serve Pulse analytics from your Express.js app. Middleware or template-based setup."
category: "backend"
brandColor: "#000000"
officialUrl: "https://expressjs.com"
relatedIds: ["flask", "nextjs", "react"]
date: "2026-03-28"
---
Add the Pulse script to your template engine's layout (EJS, Pug, Handlebars) or serve it via static HTML.
---
## Method 1: EJS template
If you use EJS as your template engine, add the script to your layout with a production guard.
<CodeBlock filename="views/layout.ejs">{`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<% if (process.env.NODE_ENV === 'production') { %>
<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>
<% } %>
<title><%= title %></title>
</head>
<body>
<%- body %>
</body>
</html>`}</CodeBlock>
## Method 2: Static HTML
If you serve static HTML files via Express, add the script directly.
<CodeBlock filename="public/index.html">{`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>
<title>My Express App</title>
</head>
<body>
<h1>Hello World</h1>
</body>
</html>`}</CodeBlock>

View File

@@ -0,0 +1,40 @@
---
title: "Flask"
description: "Add Pulse analytics to your Flask app. Jinja2 template integration."
category: "backend"
brandColor: "#3BABC3"
officialUrl: "https://flask.palletsprojects.com"
relatedIds: ["django", "htmx", "express"]
date: "2026-03-28"
---
Add the Pulse script to your Jinja2 base template with a debug guard.
---
## Add to your base template
Use Jinja2's conditional to only load the script when `DEBUG` is off.
<CodeBlock filename="templates/base.html">{`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% if not config.DEBUG %}
<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>
{% endif %}
<title>{% block title %}My Flask App{% endblock %}</title>
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>`}</CodeBlock>
For more details, see the [Flask template docs](https://flask.palletsprojects.com/en/stable/patterns/templateinheritance/).

View File

@@ -0,0 +1,25 @@
---
title: "Framer"
description: "Add Pulse analytics to your Framer site via custom code settings."
category: "platform"
brandColor: "#0055FF"
officialUrl: "https://www.framer.com/help"
relatedIds: ["webflow", "squarespace", "wix"]
date: "2026-03-28"
---
Add the Pulse script to your Framer project's custom code settings.
---
## Add via Project Settings
Go to **Project Settings -> General -> Custom Code -> Head** and paste the Pulse script.
<CodeBlock filename="Project Settings -> Head">{`<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>`}</CodeBlock>
For more details, see the [Framer custom code docs](https://www.framer.com/help/articles/custom-code/).

View File

@@ -0,0 +1,52 @@
---
title: "Gatsby"
description: "Add Pulse analytics to your Gatsby site using gatsby-ssr or the Gatsby Head API."
category: "ssg"
brandColor: "#663399"
officialUrl: "https://www.gatsbyjs.com/docs"
relatedIds: ["react", "nextjs", "hugo"]
date: "2026-03-28"
---
Use the Gatsby SSR API or the Gatsby Head API to add Pulse to your site.
---
## Method 1: gatsby-ssr.js
Use the `onRenderBody` hook to inject the Pulse script into every page's `<head>`.
<CodeBlock filename="gatsby-ssr.js">{`import React from "react"
export const onRenderBody = ({ setHeadComponents }) => {
setHeadComponents([
<script
key="pulse-analytics"
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
/>,
])
}`}</CodeBlock>
## Method 2: Gatsby Head API (v4.19+)
If you're on Gatsby 4.19 or later, you can use the Head export in any page or template component.
<CodeBlock filename="src/pages/index.tsx">{`import React from "react"
export function Head() {
return (
<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
/>
)
}
export default function IndexPage() {
return <h1>Hello World</h1>
}`}</CodeBlock>
For more details, see the [Gatsby Head API docs](https://www.gatsbyjs.com/docs/reference/built-in-components/gatsby-head/).

View File

@@ -0,0 +1,27 @@
---
title: "Ghost"
description: "Add Pulse analytics to your Ghost blog via Code Injection settings."
category: "cms"
brandColor: "#15171A"
officialUrl: "https://ghost.org/docs"
relatedIds: ["wordpress", "blogger"]
date: "2026-03-28"
---
Use Ghost's built-in Code Injection feature to add the Pulse script — no theme editing required.
---
## Add via Code Injection
Go to **Settings -> Code injection -> Site Header** and paste the Pulse script.
<CodeBlock filename="Settings -> Code injection -> Site Header">{`<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>`}</CodeBlock>
Alternatively, you can add the script directly to your theme's `default.hbs` template file.
For more details, see the [Ghost themes docs](https://ghost.org/docs/themes/).

View File

@@ -0,0 +1,39 @@
---
title: "Google Tag Manager"
description: "Add Pulse analytics via Google Tag Manager. Works with any site using GTM."
category: "platform"
brandColor: "#246FDB"
officialUrl: "https://tagmanager.google.com"
relatedIds: ["wordpress", "shopify", "webflow"]
date: "2026-03-28"
---
Add Pulse via Google Tag Manager — works with any site that already has GTM installed.
---
## Create a Custom HTML tag
Follow these steps to add Pulse through GTM:
1. Go to **Tags -> New -> Custom HTML**
2. Paste the snippet below
3. Set the trigger to **All Pages**
4. Publish your container
<CodeBlock filename="GTM -> Custom HTML Tag">{`<script defer src="https://pulse.ciphera.net/script.js"></script>`}</CodeBlock>
That's it. Pulse auto-detects the domain from the page, so no extra configuration is needed.
<details>
<summary className="cursor-pointer text-sm text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300">Advanced: override domain or configure options</summary>
If your site is registered under a different domain than the page hostname, or you need custom options (API endpoint, storage mode, etc.), use `pulseConfig`:
<CodeBlock filename="GTM -> Custom HTML Tag (with config)">{`<script>
window.pulseConfig = { domain: "your-site.com" };
</script>
<script defer src="https://pulse.ciphera.net/script.js"></script>`}</CodeBlock>
</details>
For more details, see the [GTM Custom HTML tag docs](https://support.google.com/tagmanager/answer/6103696).

View File

@@ -0,0 +1,44 @@
---
title: "Hugo"
description: "Add Pulse analytics to your Hugo site via a partial or base template."
category: "ssg"
brandColor: "#FF4088"
officialUrl: "https://gohugo.io/documentation"
relatedIds: ["jekyll", "eleventy", "astro"]
date: "2026-03-28"
---
Add the Pulse script via a Hugo partial or directly in your base template.
---
## Method 1: Create a partial
Create an analytics partial with a production guard using Hugo's `.Site.IsServer` flag.
<CodeBlock filename="layouts/partials/analytics.html">{`{{ if not .Site.IsServer }}
<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>
{{ end }}`}</CodeBlock>
## Method 2: Include the partial in your base layout
Add the partial to your `baseof.html` layout.
<CodeBlock filename="layouts/_default/baseof.html">{`<!DOCTYPE html>
<html lang="{{ .Site.Language }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{ partial "analytics.html" . }}
<title>{{ .Title }}</title>
</head>
<body>
{{ block "main" . }}{{ end }}
</body>
</html>`}</CodeBlock>
For more details, see the [Hugo partials docs](https://gohugo.io/templates/partials/).

View File

@@ -0,0 +1,41 @@
---
title: "Laravel"
description: "Add Pulse analytics to your Laravel app. Blade template integration for all Laravel versions."
category: "backend"
brandColor: "#FF2D20"
officialUrl: "https://laravel.com/docs"
relatedIds: ["django", "rails", "wordpress"]
date: "2026-03-28"
---
Add the Pulse script to your Blade layout template with a production guard.
---
## Add to your Blade layout
Use Laravel's `@production` directive to only load the script in production.
<CodeBlock filename="resources/views/layouts/app.blade.php">{`<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@production
<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>
@endproduction
<title>@yield('title')</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body>
@yield('content')
</body>
</html>`}</CodeBlock>
For more details, see the [Laravel @production docs](https://laravel.com/docs/blade#the-production-directive).

View File

@@ -0,0 +1,68 @@
---
title: "Next.js"
description: "Step-by-step guide to adding Pulse privacy-first analytics to your Next.js app with next/script. Covers App Router and Pages Router."
category: "framework"
brandColor: "#000000"
officialUrl: "https://nextjs.org/docs"
relatedIds: ["react", "vercel", "nuxt"]
date: "2026-03-28"
---
The best way to add Pulse to your Next.js application is using the built-in `next/script` component.
---
## Method 1: App Router
Add the Pulse script to your root layout so it loads on every page.
<CodeBlock filename="app/layout.tsx">{`import Script from 'next/script'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<head>
<Script
defer
src="https://pulse.ciphera.net/script.js"
data-domain="your-site.com"
strategy="afterInteractive"
/>
</head>
<body>{children}</body>
</html>
)
}`}</CodeBlock>
## Method 2: Pages Router
If you're using the Pages Router, add the script to your custom `_app.tsx`.
<CodeBlock filename="pages/_app.tsx">{`import Script from 'next/script'
import type { AppProps } from 'next/app'
export default function App({ Component, pageProps }: AppProps) {
return (
<>
<Script
defer
src="https://pulse.ciphera.net/script.js"
data-domain="your-site.com"
strategy="afterInteractive"
/>
<Component {...pageProps} />
</>
)
}`}</CodeBlock>
## Configuration options
- `data-domain` — your site's domain (without `https://`)
- `src` — the Pulse script URL
- `strategy="afterInteractive"` — loads the script after the page becomes interactive
For more details, see the [Next.js Script docs](https://nextjs.org/docs/app/api-reference/components/script).

View File

@@ -0,0 +1,49 @@
---
title: "Nuxt"
description: "Configure Pulse analytics in Nuxt 2 or Nuxt 3 via nuxt.config. Simple, framework-native setup."
category: "framework"
brandColor: "#00DC82"
officialUrl: "https://nuxt.com/docs"
relatedIds: ["vue", "nextjs", "vitepress"]
date: "2026-03-28"
---
Configure Pulse analytics in your `nuxt.config` for a framework-native setup.
---
## Method 1: Nuxt 3
Add the Pulse script via the `app.head` option in your Nuxt 3 config.
<CodeBlock filename="nuxt.config.ts">{`export default defineNuxtConfig({
app: {
head: {
script: [
{
defer: true,
'data-domain': 'your-site.com',
src: 'https://pulse.ciphera.net/script.js',
},
],
},
},
})`}</CodeBlock>
## Method 2: Nuxt 2
In Nuxt 2, use the `head` property in your config.
<CodeBlock filename="nuxt.config.js">{`export default {
head: {
script: [
{
defer: true,
'data-domain': 'your-site.com',
src: 'https://pulse.ciphera.net/script.js',
},
],
},
}`}</CodeBlock>
For more details, see the [Nuxt head config docs](https://nuxt.com/docs/api/nuxt-config#head).

View File

@@ -0,0 +1,42 @@
---
title: "Ruby on Rails"
description: "Add Pulse analytics to your Ruby on Rails app. ERB layout integration."
category: "backend"
brandColor: "#D30001"
officialUrl: "https://guides.rubyonrails.org"
relatedIds: ["laravel", "django", "jekyll"]
date: "2026-03-28"
---
Add the Pulse script to your application layout with a production environment guard.
---
## Add to your application layout
Use an `if` guard to only load the script in production.
<CodeBlock filename="app/views/layouts/application.html.erb">{`<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<% if Rails.env.production? %>
<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>
<% end %>
<title><%= yield(:title) || "My Rails App" %></title>
<%= csrf_meta_tags %>
<%= stylesheet_link_tag "application" %>
</head>
<body>
<%= yield %>
</body>
</html>`}</CodeBlock>
For more details, see the [Rails layout docs](https://guides.rubyonrails.org/layouts_and_rendering.html).

View File

@@ -0,0 +1,56 @@
---
title: "React"
description: "Integrate Pulse analytics with any React SPA — Create React App, Vite, or custom setups. Two easy methods."
category: "framework"
brandColor: "#61DAFB"
officialUrl: "https://react.dev"
relatedIds: ["nextjs", "remix", "gatsby", "preact"]
date: "2026-03-28"
---
For standard React SPAs, add the script to your `index.html`.
---
## Method 1: index.html (Recommended)
The simplest approach is to add the Pulse script directly to your HTML entry point.
<CodeBlock filename="public/index.html">{`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>
<title>My React App</title>
</head>
<body>
<div id="root"></div>
</body>
</html>`}</CodeBlock>
## Method 2: Programmatic injection via useEffect
If you prefer to inject the script programmatically (e.g. only in production), use a `useEffect` hook.
<CodeBlock filename="src/App.tsx">{`import { useEffect } from 'react'
function App() {
useEffect(() => {
if (process.env.NODE_ENV === 'production') {
const script = document.createElement('script')
script.defer = true
script.setAttribute('data-domain', 'your-site.com')
script.src = 'https://pulse.ciphera.net/script.js'
document.head.appendChild(script)
}
}, [])
return <div className="App"><h1>Hello World</h1></div>
}`}</CodeBlock>

View File

@@ -0,0 +1,50 @@
---
title: "Remix"
description: "Add Pulse analytics to your Remix application via the root route. Simple script tag in app/root.tsx."
category: "framework"
brandColor: "#000000"
officialUrl: "https://remix.run/docs"
relatedIds: ["react", "nextjs"]
date: "2026-03-28"
---
Add the Pulse script to your `app/root.tsx` so it's included on every route.
---
## Add script to app/root.tsx
The root route is the top-level layout in Remix. Add the Pulse script inside the `<head>` section.
<CodeBlock filename="app/root.tsx">{`import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from '@remix-run/react'
export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
/>
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
)
}`}</CodeBlock>
For more details, see the [Remix root docs](https://remix.run/docs/en/main/file-conventions/root).

View File

@@ -0,0 +1,45 @@
---
title: "Script Tag"
description: "Add privacy-first analytics to any website with a single script tag. Works with any platform, CMS, or framework."
category: "platform"
brandColor: "#F97316"
officialUrl: "https://pulse.ciphera.net"
relatedIds: []
date: "2026-03-28"
---
Add Pulse to any website by pasting a single script tag into your HTML. This works with any platform, CMS, or static site.
---
## Installation
Add the following script tag inside the `<head>` section of your website:
<CodeBlock filename="index.html">{`<head>
<!-- ... other head elements ... -->
<script
defer
src="https://pulse.ciphera.net/script.js"
data-domain="your-site.com"
></script>
</head>`}</CodeBlock>
## Configuration
- `data-domain` — your site's domain as shown in your Pulse dashboard (e.g. `example.com`), without `https://`
- `defer` — loads the script without blocking page rendering
## Where to paste the script
Most platforms have a "Custom Code", "Code Injection", or "Header Scripts" section in their settings. Look for one of these:
- **Squarespace:** Settings -> Developer Tools -> Code Injection -> Header
- **Wix:** Settings -> Custom Code -> Head
- **Webflow:** Project Settings -> Custom Code -> Head Code
- **Ghost:** Settings -> Code Injection -> Site Header
- **Any HTML site:** Paste directly into your `<head>` tag
## Verify installation
After deploying, visit your site and check the Pulse dashboard. You should see your first page view within a few seconds.

View File

@@ -0,0 +1,32 @@
---
title: "Shopify"
description: "Add Pulse privacy-first analytics to your Shopify store via the theme editor."
category: "ecommerce"
brandColor: "#7AB55C"
officialUrl: "https://shopify.dev/docs"
relatedIds: ["woocommerce", "bigcommerce", "prestashop"]
date: "2026-03-28"
---
Add the Pulse script via the Shopify theme editor — no app needed.
---
## Method 1: Edit theme code
Go to **Online Store -> Themes -> Edit code** and open `layout/theme.liquid`. Add the Pulse script before the closing `</head>` tag.
<CodeBlock filename="layout/theme.liquid">{`<!-- Add before </head> -->
<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>`}</CodeBlock>
## Method 2: Shopify Plus — Customer Events
If you're on Shopify Plus, you can add the Pulse script via **Settings -> Customer Events -> Custom Pixels**.
Use your custom domain or `.myshopify.com` domain as the `data-domain` value.
For more details, see the [Shopify theme docs](https://shopify.dev/docs/storefronts/themes/architecture/layouts).

View File

@@ -0,0 +1,27 @@
---
title: "Squarespace"
description: "Add Pulse analytics to your Squarespace site via the Code Injection panel."
category: "platform"
brandColor: "#000000"
officialUrl: "https://support.squarespace.com"
relatedIds: ["webflow", "wix", "carrd"]
date: "2026-03-28"
---
Use Squarespace's Code Injection feature to add the Pulse script.
---
## Add via Code Injection
Go to **Settings -> Developer Tools -> Code Injection -> Header** and paste the Pulse script.
<CodeBlock filename="Settings -> Code Injection -> Header">{`<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>`}</CodeBlock>
**Note:** Code Injection requires a Business or Commerce plan.
For more details, see the [Squarespace code injection docs](https://support.squarespace.com/hc/en-us/articles/205815928).

View File

@@ -0,0 +1,53 @@
---
title: "Svelte"
description: "Add Pulse analytics to Svelte or SvelteKit. Simple setup for both Vite-based Svelte and SvelteKit applications."
category: "framework"
brandColor: "#FF3E00"
officialUrl: "https://svelte.dev"
relatedIds: ["astro", "vue"]
date: "2026-03-28"
---
Add the script to your `index.html` for Vite-based Svelte, or use `<svelte:head>` in SvelteKit.
---
## Method 1: Svelte (Vite)
For standard Svelte projects using Vite, add the Pulse script to your `index.html`.
<CodeBlock filename="index.html">{`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>
<title>My Svelte App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>`}</CodeBlock>
## Method 2: SvelteKit
In SvelteKit, use `<svelte:head>` in your root layout to add the script to every page.
<CodeBlock filename="src/routes/+layout.svelte">{`<svelte:head>
<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>
</svelte:head>
<slot />`}</CodeBlock>
Alternatively, you can add the script directly to `src/app.html` in your SvelteKit project.

View File

@@ -0,0 +1,39 @@
---
title: "Vue.js"
description: "Add Pulse privacy-first analytics to your Vue.js app. Works with Vue 2, Vue 3, Vue CLI, and Vite."
category: "framework"
brandColor: "#4FC08D"
officialUrl: "https://vuejs.org"
relatedIds: ["nuxt", "vitepress"]
date: "2026-03-28"
---
Add the script to your `index.html` — works for both Vue CLI and Vite-based projects.
---
## Add the Pulse script to index.html
Both Vue CLI and Vite use an `index.html` as the entry point. Simply add the Pulse script inside the `<head>` tag.
<CodeBlock filename="index.html">{`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>
<title>My Vue App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>`}</CodeBlock>
Looking for Nuxt? Check the dedicated [Nuxt guide](/integrations/nuxt).

View File

@@ -0,0 +1,27 @@
---
title: "Webflow"
description: "Add Pulse analytics to your Webflow site via project custom code settings."
category: "platform"
brandColor: "#146EF5"
officialUrl: "https://university.webflow.com"
relatedIds: ["squarespace", "wix", "framer"]
date: "2026-03-28"
---
Paste the Pulse script into your Webflow project's custom code settings.
---
## Add via Project Settings
Go to **Project Settings -> Custom Code -> Head Code** and paste the Pulse script.
<CodeBlock filename="Project Settings -> Head Code">{`<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>`}</CodeBlock>
**Note:** Custom code requires a paid Webflow site plan. The script won't appear in the Designer preview — publish your site to see it in action.
For more details, see the [Webflow custom code docs](https://university.webflow.com/lesson/custom-code-in-the-head-and-body-tag).

View File

@@ -0,0 +1,27 @@
---
title: "Wix"
description: "Add Pulse analytics to your Wix site via Custom Code settings."
category: "platform"
brandColor: "#0C6EFC"
officialUrl: "https://support.wix.com"
relatedIds: ["webflow", "squarespace", "framer"]
date: "2026-03-28"
---
Use Wix's Custom Code settings to add the Pulse script.
---
## Add via Custom Code
Go to **Settings -> Custom Code -> Add Custom Code**. Set the placement to **Head** and apply it to **All pages**.
<CodeBlock filename="Custom Code Snippet">{`<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>`}</CodeBlock>
**Note:** Custom Code requires a Wix Premium plan.
For more details, see the [Wix custom code docs](https://support.wix.com/en/article/embedding-custom-code-on-your-site).

View File

@@ -0,0 +1,27 @@
---
title: "WordPress"
description: "Add Pulse analytics to your WordPress site via a plugin or theme header code."
category: "cms"
brandColor: "#21759B"
officialUrl: "https://wordpress.org/documentation"
relatedIds: ["ghost", "drupal", "woocommerce"]
date: "2026-03-28"
---
Add the Pulse script via a plugin or by editing your theme's header file directly.
---
## Method 1: Using a plugin (Recommended)
The easiest way is to use the [WPCode (Insert Headers and Footers)](https://wordpress.org/plugins/insert-headers-and-footers/) plugin. Install it, then go to **Code Snippets -> Header & Footer** and paste the Pulse script into the Header section.
<CodeBlock filename="Header Script">{`<script
defer
data-domain="your-site.com"
src="https://pulse.ciphera.net/script.js"
></script>`}</CodeBlock>
## Method 2: Edit header.php directly
Go to **Appearance -> Theme File Editor** and edit `header.php`. Add the Pulse script before the closing `</head>` tag.

View File

@@ -0,0 +1,63 @@
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
const CONTENT_DIR = path.join(process.cwd(), 'content', 'integrations')
export interface IntegrationGuideMeta {
slug: string
title: string
description: string
category: string
brandColor: string
officialUrl: string
relatedIds: string[]
date: string
}
export interface IntegrationGuideArticle extends IntegrationGuideMeta {
content: string
}
export function getIntegrationGuides(): IntegrationGuideMeta[] {
if (!fs.existsSync(CONTENT_DIR)) return []
const files = fs.readdirSync(CONTENT_DIR).filter((f) => f.endsWith('.mdx'))
return files.map((filename) => {
const slug = filename.replace(/\.mdx$/, '')
const raw = fs.readFileSync(path.join(CONTENT_DIR, filename), 'utf-8')
const { data } = matter(raw)
return {
slug,
title: data.title,
description: data.description,
category: data.category,
brandColor: data.brandColor,
officialUrl: data.officialUrl,
relatedIds: data.relatedIds || [],
date: data.date,
}
})
}
export function getIntegrationGuide(slug: string): IntegrationGuideArticle | null {
const filePath = path.join(CONTENT_DIR, `${slug}.mdx`)
if (!fs.existsSync(filePath)) return null
const raw = fs.readFileSync(filePath, 'utf-8')
const { data, content } = matter(raw)
return {
slug,
title: data.title,
description: data.description,
category: data.category,
brandColor: data.brandColor,
officialUrl: data.officialUrl,
relatedIds: data.relatedIds || [],
date: data.date,
content,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -34,10 +34,10 @@ export interface Integration {
icon: ReactNode
/** URL to official documentation / website */
officialUrl: string
/** SEO meta description for this integration's guide page */
seoDescription: string
/** Related integration IDs for cross-linking */
relatedIds: string[]
/** Whether this integration has a dedicated guide page */
dedicatedPage: boolean
}
// * ─── Category labels (for UI grouping) ──────────────────────────────────────
@@ -79,9 +79,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://nextjs.org/docs',
seoDescription:
'Step-by-step guide to adding Pulse privacy-first analytics to your Next.js app with next/script. Covers App Router and Pages Router.',
relatedIds: ['react', 'vercel', 'nuxt'],
dedicatedPage: true,
},
{
id: 'react',
@@ -95,9 +94,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://react.dev',
seoDescription:
'Integrate Pulse analytics with any React SPA — Create React App, Vite, or custom setups. Two easy methods.',
relatedIds: ['nextjs', 'remix', 'gatsby', 'preact'],
dedicatedPage: true,
},
{
id: 'vue',
@@ -111,9 +109,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://vuejs.org',
seoDescription:
'Add Pulse privacy-first analytics to your Vue.js app. Works with Vue 2, Vue 3, Vue CLI, and Vite.',
relatedIds: ['nuxt', 'vitepress'],
dedicatedPage: true,
},
{
id: 'angular',
@@ -128,9 +125,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://angular.dev',
seoDescription:
'Add Pulse analytics to your Angular application. Simple index.html setup for all Angular versions.',
relatedIds: ['react', 'vue'],
dedicatedPage: true,
},
{
id: 'svelte',
@@ -144,9 +140,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://svelte.dev',
seoDescription:
'Add Pulse analytics to Svelte or SvelteKit. Simple setup for both Vite-based Svelte and SvelteKit applications.',
relatedIds: ['astro', 'vue'],
dedicatedPage: true,
},
{
id: 'nuxt',
@@ -160,9 +155,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://nuxt.com/docs',
seoDescription:
'Configure Pulse analytics in Nuxt 2 or Nuxt 3 via nuxt.config. Simple, framework-native setup.',
relatedIds: ['vue', 'nextjs', 'vitepress'],
dedicatedPage: true,
},
{
id: 'remix',
@@ -177,9 +171,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://remix.run/docs',
seoDescription:
'Add Pulse analytics to your Remix application via the root route. Simple script tag in app/root.tsx.',
relatedIds: ['react', 'nextjs'],
dedicatedPage: true,
},
{
id: 'astro',
@@ -193,9 +186,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://docs.astro.build',
seoDescription:
'Integrate Pulse analytics with Astro. Add the script to your base layout for all pages.',
relatedIds: ['svelte', 'hugo', 'eleventy'],
dedicatedPage: true,
},
{
id: 'solidjs',
@@ -209,9 +201,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://www.solidjs.com/docs',
seoDescription:
'Add Pulse analytics to your Solid.js application. Simple index.html script tag setup.',
relatedIds: ['react', 'qwik', 'preact'],
dedicatedPage: false,
},
{
id: 'qwik',
@@ -225,9 +216,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://qwik.dev/docs',
seoDescription:
'Integrate Pulse analytics with Qwik. Add the script to your root entry file.',
relatedIds: ['react', 'solidjs', 'astro'],
dedicatedPage: false,
},
{
id: 'preact',
@@ -241,9 +231,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://preactjs.com/guide',
seoDescription:
'Add Pulse analytics to your Preact application. Same simple setup as any Vite or HTML project.',
relatedIds: ['react', 'solidjs'],
dedicatedPage: false,
},
{
id: 'htmx',
@@ -257,9 +246,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://htmx.org/docs',
seoDescription:
'Add Pulse analytics to your HTMX-powered site. Works with any backend serving HTML.',
relatedIds: ['django', 'flask', 'laravel', 'rails'],
dedicatedPage: false,
},
{
id: 'ember',
@@ -273,9 +261,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://guides.emberjs.com',
seoDescription:
'Add Pulse analytics to your Ember.js app. Simple index.html script tag setup.',
relatedIds: ['react', 'angular'],
dedicatedPage: false,
},
// * ─── Backend Frameworks ───────────────────────────────────────────────────
@@ -291,9 +278,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://laravel.com/docs',
seoDescription:
'Add Pulse analytics to your Laravel app. Blade template integration for all Laravel versions.',
relatedIds: ['django', 'rails', 'wordpress'],
dedicatedPage: true,
},
{
id: 'django',
@@ -307,9 +293,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://docs.djangoproject.com',
seoDescription:
'Add Pulse analytics to your Django app. Template-based integration for all Django versions.',
relatedIds: ['flask', 'laravel', 'htmx'],
dedicatedPage: true,
},
{
id: 'rails',
@@ -323,9 +308,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://guides.rubyonrails.org',
seoDescription:
'Add Pulse analytics to your Ruby on Rails app. ERB layout integration.',
relatedIds: ['laravel', 'django', 'jekyll'],
dedicatedPage: true,
},
{
id: 'flask',
@@ -339,9 +323,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://flask.palletsprojects.com',
seoDescription:
'Add Pulse analytics to your Flask app. Jinja2 template integration.',
relatedIds: ['django', 'htmx', 'express'],
dedicatedPage: true,
},
{
id: 'express',
@@ -356,9 +339,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://expressjs.com',
seoDescription:
'Serve Pulse analytics from your Express.js app. Middleware or template-based setup.',
relatedIds: ['flask', 'nextjs', 'react'],
dedicatedPage: true,
},
// * ─── Static Sites & Documentation ─────────────────────────────────────────
@@ -374,9 +356,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://www.gatsbyjs.com/docs',
seoDescription:
'Add Pulse analytics to your Gatsby site using gatsby-ssr or the Gatsby Head API.',
relatedIds: ['react', 'nextjs', 'hugo'],
dedicatedPage: true,
},
{
id: 'hugo',
@@ -390,9 +371,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://gohugo.io/documentation',
seoDescription:
'Add Pulse analytics to your Hugo site via a partial or base template.',
relatedIds: ['jekyll', 'eleventy', 'astro'],
dedicatedPage: true,
},
{
id: 'eleventy',
@@ -407,9 +387,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://www.11ty.dev/docs',
seoDescription:
'Add Pulse analytics to your Eleventy (11ty) site. Template-based integration.',
relatedIds: ['hugo', 'jekyll', 'astro'],
dedicatedPage: true,
},
{
id: 'jekyll',
@@ -423,9 +402,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://jekyllrb.com/docs',
seoDescription:
'Add Pulse analytics to your Jekyll site. Liquid template integration for GitHub Pages and beyond.',
relatedIds: ['hugo', 'eleventy', 'github-pages'],
dedicatedPage: false,
},
{
id: 'docusaurus',
@@ -439,9 +417,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://docusaurus.io/docs',
seoDescription:
'Add Pulse analytics to your Docusaurus documentation site via docusaurus.config.js.',
relatedIds: ['vitepress', 'mkdocs', 'gatsby'],
dedicatedPage: false,
},
{
id: 'vitepress',
@@ -455,9 +432,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://vitepress.dev',
seoDescription:
'Add Pulse analytics to your VitePress documentation site via config.',
relatedIds: ['docusaurus', 'vue', 'nuxt'],
dedicatedPage: false,
},
{
id: 'hexo',
@@ -471,9 +447,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://hexo.io/docs',
seoDescription:
'Add Pulse analytics to your Hexo blog or documentation site.',
relatedIds: ['hugo', 'jekyll', 'eleventy'],
dedicatedPage: false,
},
{
id: 'mkdocs',
@@ -487,9 +462,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://www.mkdocs.org',
seoDescription:
'Add Pulse analytics to your MkDocs documentation site.',
relatedIds: ['docusaurus', 'vitepress', 'django'],
dedicatedPage: false,
},
// * ─── CMS & Blogging ──────────────────────────────────────────────────────
@@ -505,9 +479,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://wordpress.org/documentation',
seoDescription:
'Add Pulse analytics to your WordPress site via a plugin or theme header code.',
relatedIds: ['ghost', 'drupal', 'woocommerce'],
dedicatedPage: true,
},
{
id: 'ghost',
@@ -522,9 +495,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://ghost.org/docs',
seoDescription:
'Add Pulse analytics to your Ghost blog via Code Injection settings.',
relatedIds: ['wordpress', 'blogger'],
dedicatedPage: true,
},
{
id: 'drupal',
@@ -538,9 +510,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://www.drupal.org/docs',
seoDescription:
'Add Pulse analytics to your Drupal site using a module or theme template.',
relatedIds: ['wordpress', 'joomla'],
dedicatedPage: true,
},
{
id: 'joomla',
@@ -554,9 +525,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://docs.joomla.org',
seoDescription:
'Add Pulse analytics to your Joomla site via template or extension.',
relatedIds: ['wordpress', 'drupal'],
dedicatedPage: false,
},
{
id: 'strapi',
@@ -570,9 +540,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://docs.strapi.io',
seoDescription:
'Add Pulse analytics to your Strapi-powered frontend.',
relatedIds: ['contentful', 'sanity', 'nextjs'],
dedicatedPage: false,
},
{
id: 'sanity',
@@ -587,9 +556,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://www.sanity.io/docs',
seoDescription:
'Add Pulse analytics to your Sanity-powered frontend application.',
relatedIds: ['strapi', 'contentful', 'nextjs'],
dedicatedPage: false,
},
{
id: 'contentful',
@@ -603,9 +571,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://www.contentful.com/developers/docs',
seoDescription:
'Add Pulse analytics to your Contentful-powered frontend.',
relatedIds: ['strapi', 'sanity', 'nextjs'],
dedicatedPage: false,
},
{
id: 'payload',
@@ -620,9 +587,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://payloadcms.com/docs',
seoDescription:
'Add Pulse analytics to your Payload CMS frontend application.',
relatedIds: ['strapi', 'contentful', 'nextjs'],
dedicatedPage: false,
},
// * ─── eCommerce ────────────────────────────────────────────────────────────
@@ -638,9 +604,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://shopify.dev/docs',
seoDescription:
'Add Pulse privacy-first analytics to your Shopify store via the theme editor.',
relatedIds: ['woocommerce', 'bigcommerce', 'prestashop'],
dedicatedPage: true,
},
{
id: 'woocommerce',
@@ -654,9 +619,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://woocommerce.com/documentation',
seoDescription:
'Add Pulse analytics to your WooCommerce store. WordPress-based setup.',
relatedIds: ['shopify', 'wordpress', 'bigcommerce'],
dedicatedPage: false,
},
{
id: 'bigcommerce',
@@ -671,9 +635,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://developer.bigcommerce.com/docs',
seoDescription:
'Add Pulse analytics to your BigCommerce store via Script Manager.',
relatedIds: ['shopify', 'woocommerce', 'prestashop'],
dedicatedPage: false,
},
{
id: 'prestashop',
@@ -687,9 +650,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://devdocs.prestashop-project.org',
seoDescription:
'Add Pulse analytics to your PrestaShop store via theme template.',
relatedIds: ['shopify', 'woocommerce', 'bigcommerce'],
dedicatedPage: false,
},
// * ─── Platforms & Tools ────────────────────────────────────────────────────
@@ -705,9 +667,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://university.webflow.com',
seoDescription:
'Add Pulse analytics to your Webflow site via project custom code settings.',
relatedIds: ['squarespace', 'wix', 'framer'],
dedicatedPage: true,
},
{
id: 'squarespace',
@@ -722,9 +683,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://support.squarespace.com',
seoDescription:
'Add Pulse analytics to your Squarespace site via the Code Injection panel.',
relatedIds: ['webflow', 'wix', 'carrd'],
dedicatedPage: true,
},
{
id: 'wix',
@@ -738,9 +698,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://support.wix.com',
seoDescription:
'Add Pulse analytics to your Wix site via Custom Code settings.',
relatedIds: ['webflow', 'squarespace', 'framer'],
dedicatedPage: true,
},
{
id: 'framer',
@@ -754,9 +713,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://www.framer.com/help',
seoDescription:
'Add Pulse analytics to your Framer site via custom code settings.',
relatedIds: ['webflow', 'squarespace', 'wix'],
dedicatedPage: true,
},
{
id: 'carrd',
@@ -770,9 +728,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://carrd.co/docs',
seoDescription:
'Add Pulse analytics to your Carrd one-page site via head code.',
relatedIds: ['framer', 'webflow'],
dedicatedPage: false,
},
{
id: 'blogger',
@@ -786,9 +743,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://support.google.com/blogger',
seoDescription:
'Add Pulse analytics to your Blogger blog via theme HTML editor.',
relatedIds: ['wordpress', 'ghost'],
dedicatedPage: false,
},
{
id: 'gtm',
@@ -802,9 +758,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://tagmanager.google.com',
seoDescription:
'Add Pulse analytics via Google Tag Manager. Works with any site using GTM.',
relatedIds: ['wordpress', 'shopify', 'webflow'],
dedicatedPage: true,
},
{
id: 'notion',
@@ -819,9 +774,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://www.notion.so',
seoDescription:
'Add Pulse analytics to Notion-powered websites using Super.so, Potion, or similar tools.',
relatedIds: ['webflow', 'framer', 'carrd'],
dedicatedPage: false,
},
// * ─── Hosting & Deployment ─────────────────────────────────────────────────
@@ -837,9 +791,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://developers.cloudflare.com/pages',
seoDescription:
'Deploy with Pulse analytics on Cloudflare Pages. Works with any framework.',
relatedIds: ['netlify', 'vercel', 'github-pages'],
dedicatedPage: false,
},
{
id: 'netlify',
@@ -853,9 +806,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://docs.netlify.com',
seoDescription:
'Add Pulse analytics to sites deployed on Netlify. Snippet injection and build-based methods.',
relatedIds: ['cloudflare-pages', 'vercel', 'github-pages'],
dedicatedPage: false,
},
{
id: 'vercel',
@@ -870,9 +822,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://vercel.com/docs',
seoDescription:
'Add Pulse analytics to sites deployed on Vercel. Works with any framework.',
relatedIds: ['netlify', 'cloudflare-pages', 'nextjs'],
dedicatedPage: false,
},
{
id: 'github-pages',
@@ -887,9 +838,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://docs.github.com/en/pages',
seoDescription:
'Add Pulse analytics to your GitHub Pages site. Works with Jekyll, Hugo, or plain HTML.',
relatedIds: ['jekyll', 'hugo', 'netlify'],
dedicatedPage: false,
},
// * ─── CMS & Blogging (continued) ──────────────────────────────────────────
@@ -905,9 +855,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://craftcms.com/docs',
seoDescription:
'Add Pulse analytics to your Craft CMS site via Twig templates. Simple installation.',
relatedIds: ['wordpress', 'statamic', 'drupal'],
dedicatedPage: false,
},
{
id: 'statamic',
@@ -921,9 +870,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://statamic.dev/docs',
seoDescription:
'Add Pulse analytics to your Statamic site. Antlers template integration.',
relatedIds: ['craftcms', 'laravel', 'wordpress'],
dedicatedPage: false,
},
{
id: 'typo3',
@@ -937,9 +885,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://docs.typo3.org',
seoDescription:
'Add Pulse analytics to your TYPO3 CMS site. TypoScript and Fluid template integration.',
relatedIds: ['wordpress', 'drupal', 'joomla'],
dedicatedPage: false,
},
{
id: 'kirby',
@@ -954,9 +901,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://getkirby.com/docs',
seoDescription:
'Add Pulse analytics to your Kirby CMS site. Simple PHP snippet integration.',
relatedIds: ['craftcms', 'statamic', 'grav'],
dedicatedPage: false,
},
{
id: 'grav',
@@ -971,9 +917,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://learn.getgrav.org',
seoDescription:
'Add Pulse analytics to your Grav flat-file CMS. Twig template integration.',
relatedIds: ['kirby', 'craftcms', 'hugo'],
dedicatedPage: false,
},
{
id: 'umbraco',
@@ -987,9 +932,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://docs.umbraco.com',
seoDescription:
'Add Pulse analytics to your Umbraco CMS site. Razor view integration for .NET.',
relatedIds: ['wordpress', 'drupal', 'typo3'],
dedicatedPage: false,
},
{
id: 'storyblok',
@@ -1003,9 +947,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://www.storyblok.com/docs',
seoDescription:
'Add Pulse analytics to your Storyblok-powered frontend application.',
relatedIds: ['contentful', 'prismic', 'nextjs'],
dedicatedPage: false,
},
{
id: 'prismic',
@@ -1019,9 +962,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://prismic.io/docs',
seoDescription:
'Add Pulse analytics to your Prismic-powered frontend application.',
relatedIds: ['contentful', 'storyblok', 'nextjs'],
dedicatedPage: false,
},
// * ─── eCommerce (continued) ───────────────────────────────────────────────
@@ -1037,9 +979,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://developer.shopware.com/docs',
seoDescription:
'Add Pulse analytics to your Shopware 6 store via theme template.',
relatedIds: ['shopify', 'woocommerce', 'magento'],
dedicatedPage: false,
},
{
id: 'magento',
@@ -1053,9 +994,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://developer.adobe.com/commerce',
seoDescription:
'Add Pulse analytics to your Magento or Adobe Commerce store via layout XML.',
relatedIds: ['shopify', 'woocommerce', 'shopware'],
dedicatedPage: false,
},
// * ─── Platforms & Tools (continued) ───────────────────────────────────────
@@ -1072,9 +1012,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://manual.bubble.io',
seoDescription:
'Add Pulse analytics to your Bubble no-code app via the SEO/Meta tags section.',
relatedIds: ['webflow', 'framer', 'wix'],
dedicatedPage: false,
},
{
id: 'discourse',
@@ -1089,9 +1028,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://meta.discourse.org/docs',
seoDescription:
'Add Pulse analytics to your Discourse forum via admin customization.',
relatedIds: ['wordpress', 'ghost'],
dedicatedPage: false,
},
{
id: 'hubspot',
@@ -1105,9 +1043,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://knowledge.hubspot.com',
seoDescription:
'Add Pulse analytics to HubSpot landing pages and website via Settings.',
relatedIds: ['wordpress', 'webflow'],
dedicatedPage: false,
},
{
id: 'substack',
@@ -1121,9 +1058,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://substack.com',
seoDescription:
'Add Pulse analytics to your Substack publication using custom domain settings.',
relatedIds: ['ghost', 'blogger', 'wordpress'],
dedicatedPage: false,
},
{
id: 'linktree',
@@ -1137,9 +1073,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://linktr.ee',
seoDescription:
'Add Pulse analytics to your Linktree link-in-bio page.',
relatedIds: ['carrd', 'framer', 'webflow'],
dedicatedPage: false,
},
{
id: 'weebly',
@@ -1153,9 +1088,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://www.weebly.com',
seoDescription:
'Add Pulse analytics to your Weebly website via the header code settings.',
relatedIds: ['squarespace', 'wix', 'webflow'],
dedicatedPage: false,
},
// * ─── Static Sites & Documentation (continued) ───────────────────────────
@@ -1171,9 +1105,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://docs.gitbook.com',
seoDescription:
'Add Pulse analytics to your GitBook-hosted documentation.',
relatedIds: ['docusaurus', 'readme', 'readthedocs'],
dedicatedPage: false,
},
{
id: 'gridsome',
@@ -1187,9 +1120,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://gridsome.org/docs',
seoDescription:
'Add Pulse analytics to your Gridsome Vue-based static site.',
relatedIds: ['gatsby', 'vue', 'nuxt'],
dedicatedPage: false,
},
{
id: 'readthedocs',
@@ -1204,9 +1136,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://docs.readthedocs.io',
seoDescription:
'Add Pulse analytics to your Read the Docs documentation site.',
relatedIds: ['sphinx', 'mkdocs', 'docusaurus'],
dedicatedPage: false,
},
{
id: 'sphinx',
@@ -1221,9 +1152,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://www.sphinx-doc.org',
seoDescription:
'Add Pulse analytics to your Sphinx-generated documentation.',
relatedIds: ['readthedocs', 'mkdocs', 'docusaurus'],
dedicatedPage: false,
},
{
id: 'readme',
@@ -1237,9 +1167,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://docs.readme.com',
seoDescription:
'Add Pulse analytics to your ReadMe API documentation portal.',
relatedIds: ['gitbook', 'docusaurus', 'readthedocs'],
dedicatedPage: false,
},
// * ─── JavaScript Frameworks (continued) ───────────────────────────────────
@@ -1255,9 +1184,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://docs.flutter.dev',
seoDescription:
'Add Pulse analytics to your Flutter web app via web/index.html.',
relatedIds: ['react', 'angular', 'preact'],
dedicatedPage: false,
},
// * ─── Hosting & Deployment (continued) ────────────────────────────────────
@@ -1274,9 +1202,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://docs.render.com',
seoDescription:
'Add Pulse analytics to sites deployed on Render. Works with any framework.',
relatedIds: ['netlify', 'vercel', 'cloudflare-pages'],
dedicatedPage: false,
},
{
id: 'firebase',
@@ -1290,9 +1217,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://firebase.google.com/docs/hosting',
seoDescription:
'Add Pulse analytics to sites deployed on Firebase Hosting.',
relatedIds: ['netlify', 'vercel', 'render'],
dedicatedPage: false,
},
// * ─── Platforms & Tools (continued) ───────────────────────────────────────
@@ -1308,9 +1234,8 @@ export const integrations: Integration[] = [
</svg>
),
officialUrl: 'https://amp.dev/documentation',
seoDescription:
'Add Pulse analytics to Google AMP pages using amp-analytics.',
relatedIds: ['gtm', 'wordpress', 'webflow'],
dedicatedPage: false,
},
]

View File

@@ -33,11 +33,11 @@ export const TRAFFIC_TIERS = [
export function getTierIndexForLimit(limit: number): number {
const idx = TRAFFIC_TIERS.findIndex((t) => t.value === limit)
return idx >= 0 ? idx : 2
return idx >= 0 ? idx : 0
}
export function getLimitForTierIndex(index: number): number {
if (index < 0 || index >= TRAFFIC_TIERS.length) return 100000
if (index < 0 || index >= TRAFFIC_TIERS.length) return 10000
return TRAFFIC_TIERS[index].value
}

View File

@@ -2,7 +2,7 @@
import { createContext, useContext, useState, useCallback } from 'react'
type InitialTab = { context?: 'site' | 'workspace' | 'account'; tab?: string } | null
type InitialTab = { context?: 'site' | 'workspace' | 'account'; tab?: string; siteId?: string } | null
interface UnifiedSettingsContextType {
isOpen: boolean

View File

@@ -76,12 +76,31 @@ const nextConfig: NextConfig = {
]
},
async redirects() {
const removedIntegrations = [
'solidjs', 'qwik', 'preact', 'htmx', 'ember',
'jekyll', 'docusaurus', 'vitepress', 'hexo', 'mkdocs',
'joomla', 'strapi', 'sanity', 'contentful', 'payload',
'craftcms', 'statamic', 'typo3', 'kirby', 'grav', 'umbraco',
'storyblok', 'prismic', 'shopware', 'magento',
'woocommerce', 'bigcommerce', 'prestashop',
'blogger', 'substack', 'linktree', 'weebly', 'gitbook',
'gridsome', 'readthedocs', 'sphinx', 'readme',
'bubble', 'discourse', 'hubspot', 'notion',
'cloudflare-pages', 'netlify', 'vercel', 'github-pages',
'firebase', 'render', 'flutter', 'amp', 'carrd',
]
return [
{
source: '/dashboard',
destination: '/',
permanent: false,
},
...removedIntegrations.map((slug) => ({
source: `/integrations/${slug}`,
destination: '/integrations/script-tag',
permanent: true,
})),
]
},
async rewrites() {

1010
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,8 @@
"private": true,
"scripts": {
"dev": "next dev",
"generate:integrations": "npx tsx scripts/generate-integrations.ts",
"prebuild": "npm run generate:integrations",
"build": "next build --webpack",
"start": "next start",
"lint": "next lint",
@@ -37,18 +39,21 @@
"d3-array": "^3.2.4",
"d3-scale": "^4.0.2",
"framer-motion": "^12.23.26",
"gray-matter": "^4.0.3",
"html-to-image": "^1.11.13",
"iso-3166-2": "^1.0.0",
"jspdf": "^4.0.0",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^0.577.0",
"next": "^16.1.1",
"next-mdx-remote": "^6.0.0",
"radix-ui": "^1.4.3",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-markdown": "^10.1.0",
"react-use-measure": "^2.1.7",
"recharts": "^2.15.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"svg-dotted-map": "^2.0.1",
"swr": "^2.3.3",

View File

@@ -0,0 +1,44 @@
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
const CONTENT_DIR = path.join(process.cwd(), 'content', 'integrations')
const OUTPUT_PATH = path.join(process.cwd(), 'lib', 'integration-guides.gen.ts')
const files = fs.existsSync(CONTENT_DIR)
? fs.readdirSync(CONTENT_DIR).filter((f) => f.endsWith('.mdx'))
: []
const guides: { slug: string; title: string; description: string; category: string; date: string }[] = []
for (const filename of files) {
const slug = filename.replace(/\.mdx$/, '')
const raw = fs.readFileSync(path.join(CONTENT_DIR, filename), 'utf-8')
const { data } = matter(raw)
guides.push({
slug,
title: data.title as string,
description: data.description as string,
category: data.category as string,
date: data.date as string,
})
}
guides.sort((a, b) => a.title.localeCompare(b.title))
const output = `// Auto-generated from content/integrations/*.mdx — do not edit manually
// Run: npm run generate:integrations
export interface IntegrationGuideSummary {
slug: string
title: string
description: string
category: string
date: string
}
export const integrationGuides: IntegrationGuideSummary[] = ${JSON.stringify(guides, null, 2)}
`
fs.writeFileSync(OUTPUT_PATH, output, 'utf-8')
console.log(`Generated ${guides.length} integration guides → lib/integration-guides.gen.ts`)