Dashboard shell, breadcrumb navigation, sidebar redesign & integration pages SEO overhaul #73
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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><head></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>
|
||||
)
|
||||
}
|
||||
147
app/integrations/script-tag/page.tsx
Normal file
147
app/integrations/script-tag/page.tsx
Normal 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><head></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> — your site's domain as shown in your Pulse dashboard (e.g. <code>example.com</code>), without <code>https://</code></li>
|
||||
<li><code>defer</code> — loads the script without blocking page rendering</li>
|
||||
</ul>
|
||||
|
||||
<h2>Where to paste the script</h2>
|
||||
<p>
|
||||
Most platforms have a “Custom Code”, “Code Injection”, or “Header Scripts”
|
||||
section in their settings. Look for one of these:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Squarespace:</strong> Settings → Developer Tools → Code Injection → Header</li>
|
||||
<li><strong>Wix:</strong> Settings → Custom Code → Head</li>
|
||||
<li><strong>Webflow:</strong> Project Settings → Custom Code → Head Code</li>
|
||||
<li><strong>Ghost:</strong> Settings → Code Injection → Site Header</li>
|
||||
<li><strong>Any HTML site:</strong> Paste directly into your <code><head></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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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><head></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>
|
||||
)
|
||||
}
|
||||
@@ -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 > 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></head></code> tag.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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}</>
|
||||
|
||||
108
app/page.tsx
108
app/page.tsx
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
39
content/integrations/angular.mdx
Normal file
39
content/integrations/angular.mdx
Normal 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).
|
||||
48
content/integrations/astro.mdx
Normal file
48
content/integrations/astro.mdx
Normal 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/).
|
||||
42
content/integrations/django.mdx
Normal file
42
content/integrations/django.mdx
Normal 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/).
|
||||
45
content/integrations/drupal.mdx
Normal file
45
content/integrations/drupal.mdx
Normal 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).
|
||||
38
content/integrations/eleventy.mdx
Normal file
38
content/integrations/eleventy.mdx
Normal 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/).
|
||||
61
content/integrations/express.mdx
Normal file
61
content/integrations/express.mdx
Normal 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>
|
||||
40
content/integrations/flask.mdx
Normal file
40
content/integrations/flask.mdx
Normal 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/).
|
||||
25
content/integrations/framer.mdx
Normal file
25
content/integrations/framer.mdx
Normal 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/).
|
||||
52
content/integrations/gatsby.mdx
Normal file
52
content/integrations/gatsby.mdx
Normal 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/).
|
||||
27
content/integrations/ghost.mdx
Normal file
27
content/integrations/ghost.mdx
Normal 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/).
|
||||
39
content/integrations/gtm.mdx
Normal file
39
content/integrations/gtm.mdx
Normal 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).
|
||||
44
content/integrations/hugo.mdx
Normal file
44
content/integrations/hugo.mdx
Normal 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/).
|
||||
41
content/integrations/laravel.mdx
Normal file
41
content/integrations/laravel.mdx
Normal 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).
|
||||
68
content/integrations/nextjs.mdx
Normal file
68
content/integrations/nextjs.mdx
Normal 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).
|
||||
49
content/integrations/nuxt.mdx
Normal file
49
content/integrations/nuxt.mdx
Normal 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).
|
||||
42
content/integrations/rails.mdx
Normal file
42
content/integrations/rails.mdx
Normal 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).
|
||||
56
content/integrations/react.mdx
Normal file
56
content/integrations/react.mdx
Normal 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>
|
||||
50
content/integrations/remix.mdx
Normal file
50
content/integrations/remix.mdx
Normal 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).
|
||||
45
content/integrations/script-tag.mdx
Normal file
45
content/integrations/script-tag.mdx
Normal 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.
|
||||
32
content/integrations/shopify.mdx
Normal file
32
content/integrations/shopify.mdx
Normal 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).
|
||||
27
content/integrations/squarespace.mdx
Normal file
27
content/integrations/squarespace.mdx
Normal 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).
|
||||
53
content/integrations/svelte.mdx
Normal file
53
content/integrations/svelte.mdx
Normal 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.
|
||||
39
content/integrations/vue.mdx
Normal file
39
content/integrations/vue.mdx
Normal 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).
|
||||
27
content/integrations/webflow.mdx
Normal file
27
content/integrations/webflow.mdx
Normal 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).
|
||||
27
content/integrations/wix.mdx
Normal file
27
content/integrations/wix.mdx
Normal 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).
|
||||
27
content/integrations/wordpress.mdx
Normal file
27
content/integrations/wordpress.mdx
Normal 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.
|
||||
63
lib/integration-content.ts
Normal file
63
lib/integration-content.ts
Normal 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
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
1010
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
44
scripts/generate-integrations.ts
Normal file
44
scripts/generate-integrations.ts
Normal 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`)
|
||||
Reference in New Issue
Block a user