Merge pull request #67 from ciphera-net/staging

Landing page redesign, dashboard improvements & new settings sections
This commit is contained in:
Usman
2026-03-22 17:17:38 +01:00
committed by GitHub
71 changed files with 9154 additions and 2419 deletions

1
.npmrc
View File

@@ -1,2 +1,3 @@
@ciphera-net:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
legacy-peer-deps=true

View File

@@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Improved
- **Redesigned Search card on the dashboard.** The Search section of the dashboard has been completely refreshed to match the rest of Pulse. Search queries now show proportional bars so you can visually compare which queries get the most impressions. Hovering a row reveals the impression share percentage. Position badges are now color-coded — green for page 1 rankings, orange for page 2, and red for queries buried beyond page 5. You can switch between your top search queries and top pages using tabs, and expand the full list in a searchable popup without leaving the dashboard.
- **Smaller, faster tracking script.** The tracking script is now about 20% smaller. Logic like page path cleaning, referrer filtering, error page detection, and input validation has been moved from your browser to the Pulse server. This means the script loads faster on every page, and Pulse can improve these features without needing you to update anything.
- **Automatic 404 page detection.** Pulse now detects error pages (404 / "Page Not Found") automatically on the server by reading your page title — no extra setup needed. Previously this ran in the browser and couldn't be improved without updating the script. Now Pulse can recognize more error page patterns over time, including pages in other languages, without any changes on your end.
- **Smarter bot filtering.** Pulse now catches more types of automated traffic that were slipping through — like headless browsers with default screen sizes, bot farms that rotate through different locations, and bots that fire duplicate events within milliseconds. Bot detection checks have also been moved from the tracking script to the server, making the script smaller and faster for real visitors.

View File

@@ -15,23 +15,23 @@ function ComparisonTable({ title, competitors }: { title: string, competitors: {
return (
<div className="mb-16">
<h2 className="text-2xl font-bold mb-6 text-neutral-900 dark:text-white">{title}</h2>
<div className="overflow-hidden rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm">
<h2 className="text-2xl font-bold mb-6 text-white">{title}</h2>
<div className="overflow-hidden rounded-2xl border border-neutral-800 bg-neutral-900/50 backdrop-blur-sm">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-neutral-200 dark:border-neutral-800">
<tr className="border-b border-neutral-800">
<th className="p-4 sm:p-6 text-sm font-medium text-neutral-500">Feature</th>
{competitors.map((comp) => (
<th key={comp.name} className={`p-4 sm:p-6 text-sm font-bold ${comp.isPulse ? 'text-brand-orange' : 'text-neutral-900 dark:text-white'}`}>
<th key={comp.name} className={`p-4 sm:p-6 text-sm font-bold ${comp.isPulse ? 'text-brand-orange' : 'text-white'}`}>
{comp.name}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
<tbody className="divide-y divide-neutral-800">
{allFeatures.map((feature) => (
<tr key={feature} className="hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 transition-colors">
<td className="p-4 sm:p-6 text-neutral-900 dark:text-white font-medium text-sm sm:text-base">{feature}</td>
<tr key={feature} className="hover:bg-neutral-800/50 transition-colors">
<td className="p-4 sm:p-6 text-white font-medium text-sm sm:text-base">{feature}</td>
{competitors.map((comp) => {
const val = comp.features[feature]
return (
@@ -41,7 +41,7 @@ function ComparisonTable({ title, competitors }: { title: string, competitors: {
) : val === false ? (
<XIcon className="w-5 h-5 text-red-500" />
) : (
<span className={comp.isPulse ? 'text-green-500 font-medium' : 'text-neutral-600 dark:text-neutral-400'}>{val}</span>
<span className={comp.isPulse ? 'text-green-500 font-medium' : 'text-neutral-400'}>{val}</span>
)}
</td>
)
@@ -60,10 +60,9 @@ export default function AboutPage() {
<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 top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
<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.02] dark:opacity-[0.05]"
className="absolute inset-0 bg-grid-pattern opacity-[0.05]"
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
/>
</div>
@@ -75,10 +74,10 @@ export default function AboutPage() {
transition={{ duration: 0.5 }}
className="text-center mb-16"
>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white mb-6">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-white mb-6">
Why Pulse?
</h1>
<p className="text-xl text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto leading-relaxed">
<p className="text-xl text-neutral-400 max-w-2xl mx-auto leading-relaxed">
We built Pulse because we were tired of complex, invasive analytics tools.
Here is how we stack up against the giants.
</p>
@@ -88,9 +87,9 @@ export default function AboutPage() {
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="prose prose-neutral dark:prose-invert max-w-none mb-16"
className="prose prose-invert max-w-none mb-16"
>
<p className="text-lg text-neutral-600 dark:text-neutral-400">
<p className="text-lg text-neutral-400">
Most analytics tools are overkill. They track everything, slow down your site, and require annoying cookie banners.
Pulse is different. We focus on the metrics that actually mattervisitors, pageviews, and sourceswhile respecting user privacy.
</p>
@@ -163,10 +162,10 @@ export default function AboutPage() {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="mt-8 p-6 bg-neutral-100 dark:bg-neutral-800/50 rounded-xl border border-neutral-200 dark:border-neutral-800"
className="mt-8 p-6 bg-neutral-800/50 rounded-xl border border-neutral-800"
>
<h3 className="text-xl font-bold mb-2 text-neutral-900 dark:text-white">What about Plausible?</h3>
<p className="text-neutral-600 dark:text-neutral-400 text-sm">
<h3 className="text-xl font-bold mb-2 text-white">What about Plausible?</h3>
<p className="text-neutral-400 text-sm">
We love Plausible! They paved the way for privacy-friendly analytics.
Pulse offers a similar philosophy but with a focus on even deeper integration with the Ciphera ecosystem
and more flexible pricing for developers.

View File

@@ -1,37 +1,18 @@
'use client'
import { motion } from 'framer-motion'
import { useState } from 'react'
import { ChevronDownIcon } from '@ciphera-net/ui'
const faqs = [
{
question: "Is Pulse GDPR compliant?",
answer: "Yes, Pulse is GDPR compliant by design. We don't use cookies, don't collect personal data, and process all data anonymously."
},
{
question: "Do I need a cookie consent banner?",
answer: "No, you don't need a cookie consent banner. Pulse doesn't use cookies, so it's exempt from cookie consent requirements under GDPR."
},
{
question: "How does Pulse track visitors?",
answer: "We use a lightweight JavaScript snippet that sends anonymous pageview events. No cookies, no cross-session identifiers (we use sessionStorage only to group events within a single visit), and no cross-site tracking."
},
{
question: "What data does Pulse collect?",
answer: "We collect anonymous pageview data including page path, referrer, device type, browser, and country (derived from IP at request time; the IP itself is not stored). No personal information is collected."
},
{
question: "How accurate is the data?",
answer: "Our data is highly accurate. We exclude bot traffic and data center visits. Since we don't use cookies, we count unique sessions rather than unique users."
},
{
question: "Can I export my data?",
answer: "Yes, you can access all your analytics data through the dashboard. We're working on export functionality for bulk data downloads."
}
]
import PulseFAQ from '@/components/marketing/PulseFAQ'
// * JSON-LD FAQ Schema for rich snippets
const faqs = [
{ question: "Is Pulse GDPR compliant?", answer: "Yes, Pulse is GDPR compliant by design. We don't use cookies, don't collect personal data, and process all data anonymously." },
{ question: "Do I need a cookie consent banner?", answer: "No, you don't need a cookie consent banner. Pulse doesn't use cookies, so it's exempt from cookie consent requirements under GDPR." },
{ question: "How does Pulse track visitors?", answer: "We use a lightweight JavaScript snippet that sends anonymous pageview events. No cookies, no cross-session identifiers (we use sessionStorage only to group events within a single visit), and no cross-site tracking." },
{ question: "What data does Pulse collect?", answer: "We collect anonymous pageview data including page path, referrer, device type, browser, and country (derived from IP at request time; the IP itself is not stored). No personal information is collected." },
{ question: "How accurate is the data?", answer: "Our data is highly accurate. We exclude bot traffic and data center visits. Since we don't use cookies, we count unique sessions rather than unique users." },
{ question: "Can I export my data?", answer: "Yes, you can access all your analytics data through the dashboard. We're working on export functionality for bulk data downloads." },
]
const faqSchema = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
@@ -45,47 +26,6 @@ const faqSchema = {
})),
}
function FAQItem({ faq, index }: { faq: typeof faqs[0]; index: number }) {
const [isOpen, setIsOpen] = useState(false)
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.05 }}
className="border-b border-neutral-200 dark:border-neutral-800"
>
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full py-6 flex items-center justify-between text-left hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white pr-4">
{faq.question}
</h3>
<ChevronDownIcon
className={`w-5 h-5 text-neutral-500 shrink-0 transition-transform duration-300 ${
isOpen ? 'rotate-180' : ''
}`}
/>
</button>
{isOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
className="pb-6"
>
<p className="text-neutral-600 dark:text-neutral-400 leading-relaxed">
{faq.answer}
</p>
</motion.div>
)}
</motion.div>
)
}
export default function FAQPage() {
return (
<>
@@ -94,29 +34,9 @@ export default function FAQPage() {
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
/>
<div className="container mx-auto px-4 py-16 max-w-4xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-center mb-16"
>
<span className="badge-primary mb-4 inline-flex">FAQ</span>
<h1 className="text-4xl md:text-5xl font-bold text-neutral-900 dark:text-white mb-4">
Frequently asked questions
</h1>
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
Learn more about how Pulse respects your privacy and handles your data.
</p>
</motion.div>
<div className="max-w-3xl mx-auto">
{faqs.map((faq, index) => (
<FAQItem key={faq.question} faq={faq} index={index} />
))}
</div>
<div className="pt-8 pb-16">
<PulseFAQ />
{/* * CTA */}
<motion.div
@@ -126,12 +46,12 @@ export default function FAQPage() {
transition={{ duration: 0.5, delay: 0.3 }}
className="text-center mt-12"
>
<p className="text-neutral-600 dark:text-neutral-400 mb-4">
<p className="text-neutral-400 mb-4">
Still have questions?
</p>
<a
href="mailto:support@ciphera.net"
className="inline-flex items-center justify-center gap-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 text-neutral-900 dark:text-white px-5 py-2.5 rounded-xl font-medium hover:bg-neutral-50 dark:hover:bg-neutral-800 shadow-sm hover:shadow-md dark:shadow-none transition-all duration-200"
className="inline-flex items-center justify-center gap-2 bg-neutral-900 border border-neutral-800 text-white px-5 py-2.5 rounded-xl font-medium hover:bg-neutral-800 transition-all duration-200"
>
Contact us
</a>

View File

@@ -109,10 +109,9 @@ export default function FeaturesPage() {
<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 top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
<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.02] dark:opacity-[0.05]"
className="absolute inset-0 bg-grid-pattern opacity-[0.05]"
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
/>
</div>
@@ -129,11 +128,11 @@ export default function FeaturesPage() {
<span className="w-1.5 h-1.5 rounded-full bg-brand-orange animate-pulse" />
Product Tour
</span>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white mb-6">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-white mb-6">
Everything you need. <br />
<span className="gradient-text">Nothing you don&apos;t.</span>
</h1>
<p className="text-xl text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto leading-relaxed">
<p className="text-xl text-neutral-400 max-w-2xl mx-auto leading-relaxed">
Pulse gives you meaningful analytics without the complexity, the cookies, or the privacy trade-offs.
</p>
</motion.div>
@@ -152,10 +151,10 @@ export default function FeaturesPage() {
<div className="w-12 h-12 rounded-xl bg-brand-orange/10 flex items-center justify-center mb-6 text-brand-orange group-hover:scale-110 transition-transform duration-300">
<feature.icon className="w-6 h-6" />
</div>
<h3 className="text-xl font-bold text-neutral-900 dark:text-white mb-3">
<h3 className="text-xl font-bold text-white mb-3">
{feature.title}
</h3>
<p className="text-neutral-600 dark:text-neutral-400 leading-relaxed">
<p className="text-neutral-400 leading-relaxed">
{feature.description}
</p>
</motion.div>
@@ -171,10 +170,10 @@ export default function FeaturesPage() {
className="mb-28"
>
<div className="text-center mb-14">
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
<h2 className="text-2xl font-bold text-white mb-4">
Powerful analytics, <span className="gradient-text">simplified</span>
</h2>
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
<p className="text-lg text-neutral-400 max-w-2xl mx-auto">
Everything from real-time dashboards to conversion funnels without the bloat.
</p>
</div>
@@ -193,10 +192,10 @@ export default function FeaturesPage() {
{cap.icon}
</div>
<div>
<h3 className="font-bold text-neutral-900 dark:text-white mb-1">
<h3 className="font-bold text-white mb-1">
{cap.title}
</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400 leading-relaxed">
<p className="text-sm text-neutral-400 leading-relaxed">
{cap.description}
</p>
</div>
@@ -211,14 +210,14 @@ export default function FeaturesPage() {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="mb-28 p-10 md:p-14 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800 rounded-2xl"
className="mb-28 p-10 md:p-14 bg-neutral-900/50 backdrop-blur-sm border border-neutral-800 rounded-2xl"
>
<div className="grid md:grid-cols-2 gap-10 items-center">
<div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
<h2 className="text-2xl font-bold text-white mb-4">
Content that <span className="gradient-text">performs</span>
</h2>
<p className="text-neutral-600 dark:text-neutral-400 leading-relaxed mb-6">
<p className="text-neutral-400 leading-relaxed mb-6">
See which pages drive the most traffic, where visitors enter your site, and where they leave. Use data to double down on what works.
</p>
<ul className="space-y-3">
@@ -229,7 +228,7 @@ export default function FeaturesPage() {
'Referral sources — where traffic comes from',
'Browser, OS & device breakdowns',
].map((item) => (
<li key={item} className="flex items-start gap-3 text-sm text-neutral-600 dark:text-neutral-400">
<li key={item} className="flex items-start gap-3 text-sm text-neutral-400">
<svg className="w-5 h-5 text-brand-orange shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
@@ -251,17 +250,17 @@ export default function FeaturesPage() {
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.1 }}
className="p-4 bg-neutral-50 dark:bg-neutral-800/50 rounded-xl"
className="p-4 bg-neutral-800/50 rounded-xl"
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-neutral-900 dark:text-white truncate mr-4">
<span className="text-sm font-medium text-white truncate mr-4">
{page.label}
</span>
<span className="text-sm text-neutral-500 dark:text-neutral-400 shrink-0">
<span className="text-sm text-neutral-400 shrink-0">
{page.views} views
</span>
</div>
<div className="h-1.5 bg-neutral-200 dark:bg-neutral-700 rounded-full overflow-hidden">
<div className="h-1.5 bg-neutral-700 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
whileInView={{ width: `${page.pct}%` }}
@@ -285,10 +284,10 @@ export default function FeaturesPage() {
className="mb-28"
>
<div className="text-center mb-12">
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
<h2 className="text-2xl font-bold text-white mb-4">
Built for trust
</h2>
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
<p className="text-lg text-neutral-400 max-w-2xl mx-auto">
Open source, Swiss hosted, and designed to keep your visitors&apos; data where it belongs.
</p>
</div>
@@ -307,8 +306,8 @@ export default function FeaturesPage() {
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
<div>
<span className="font-semibold text-neutral-900 dark:text-white text-sm">{signal.label}</span>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">{signal.detail}</p>
<span className="font-semibold text-white text-sm">{signal.label}</span>
<p className="text-xs text-neutral-400 mt-0.5">{signal.detail}</p>
</div>
</motion.div>
))}
@@ -341,10 +340,10 @@ export default function FeaturesPage() {
className="mb-28"
>
<div className="text-center mb-14">
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
<h2 className="text-2xl font-bold text-white mb-4">
Up and running in <span className="gradient-text">3 minutes</span>
</h2>
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
<p className="text-lg text-neutral-400 max-w-2xl mx-auto">
No SDKs to install, no build steps, no configuration files.
</p>
</div>
@@ -367,15 +366,15 @@ export default function FeaturesPage() {
{s.step}
</div>
<div>
<h3 className="font-bold text-neutral-900 dark:text-white text-sm">
<h3 className="font-bold text-white text-sm">
{s.title}
</h3>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
<p className="text-xs text-neutral-400">
{s.desc}
</p>
</div>
{i < 2 && (
<ArrowRightIcon className="w-5 h-5 text-neutral-300 dark:text-neutral-600 shrink-0 hidden md:block" />
<ArrowRightIcon className="w-5 h-5 text-neutral-600 shrink-0 hidden md:block" />
)}
</motion.div>
))}
@@ -390,10 +389,10 @@ export default function FeaturesPage() {
transition={{ duration: 0.5 }}
className="text-center mb-20"
>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
<h2 className="text-2xl font-bold text-white mb-4">
Ready to see it in action?
</h2>
<p className="text-neutral-600 dark:text-neutral-400 mb-8 max-w-lg mx-auto">
<p className="text-neutral-400 mb-8 max-w-lg mx-auto">
Start for free. No credit card required. Cancel anytime.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">

View File

@@ -8,32 +8,30 @@ export default function InstallationPage() {
{/* * --- 1. ATMOSPHERE (Background) --- */}
<div className="absolute inset-0 -z-10 pointer-events-none">
{/* * Top-left Orange Glow */}
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
{/* * Bottom-right Neutral Glow */}
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
{/* * Grid Pattern with Radial Mask */}
<div
className="absolute inset-0 bg-grid-pattern opacity-[0.02] dark:opacity-[0.05]"
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">
<div className="text-center mb-12">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white mb-6">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-white mb-6">
Installation
</h1>
<p className="text-xl text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto leading-relaxed">
<p className="text-xl text-neutral-400 max-w-2xl mx-auto leading-relaxed">
Get up and running with Pulse in seconds.
</p>
</div>
<div className="w-full text-center">
<h2 className="text-2xl font-bold mb-8 text-neutral-900 dark:text-white">Add the snippet</h2>
<h2 className="text-2xl font-bold mb-8 text-white">Add the snippet</h2>
<p className="text-neutral-500 mb-8">Just add this snippet to your &lt;head&gt; tag in your layout or index file.</p>
<div className="max-w-2xl mx-auto bg-neutral-900 rounded-xl overflow-hidden shadow-2xl text-left border border-neutral-800">
<div className="max-w-2xl mx-auto bg-neutral-900/80 rounded-xl overflow-hidden shadow-2xl text-left border border-white/[0.08]">
<div className="flex items-center px-4 py-3 bg-neutral-800 border-b border-neutral-800">
<div className="flex gap-2">
<div className="w-3 h-3 rounded-full bg-red-500/20" />
@@ -55,15 +53,22 @@ export default function InstallationPage() {
<span className="text-blue-400">&gt;&lt;/script&gt;</span>
</code>
</div>
<div className="flex items-center gap-4 px-6 py-3 border-t border-neutral-800 text-xs text-neutral-500">
<span>1.6 KB gzipped</span>
<span className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-green-500" />
Non-blocking, async
</span>
</div>
</div>
</div>
<div className="w-full mt-16 text-center">
<h2 className="text-2xl font-bold mb-4 text-neutral-900 dark:text-white">Custom events (goals)</h2>
<h2 className="text-2xl font-bold mb-4 text-white">Custom events (goals)</h2>
<p className="text-neutral-500 mb-6 max-w-xl mx-auto">
Track custom events (e.g. signup, purchase) with <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-sm font-mono">pulse.track(&apos;event_name&apos;)</code>. Use letters, numbers, and underscores only. Define goals in your site Settings Goals & Events to see counts in the dashboard.
Track custom events (e.g. signup, purchase) with <code className="px-1.5 py-0.5 rounded bg-neutral-700 text-sm font-mono">pulse.track(&apos;event_name&apos;)</code>. Use letters, numbers, and underscores only. Define goals in your site Settings Goals & Events to see counts in the dashboard.
</p>
<div className="max-w-2xl mx-auto bg-neutral-900 rounded-xl overflow-hidden shadow-2xl text-left border border-neutral-800">
<div className="max-w-2xl mx-auto bg-neutral-900/80 rounded-xl overflow-hidden shadow-2xl text-left border border-white/[0.08]">
<div className="flex items-center px-4 py-3 bg-neutral-800 border-b border-neutral-800">
<div className="flex gap-2">
<div className="w-3 h-3 rounded-full bg-red-500/20" />

View File

@@ -8,10 +8,9 @@ export default function NextJsIntegrationPage() {
<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 top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
<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.02] dark:opacity-[0.05]"
className="absolute inset-0 bg-grid-pattern opacity-[0.05]"
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
/>
</div>
@@ -26,22 +25,22 @@ export default function NextJsIntegrationPage() {
</Link>
<div className="flex items-center gap-4 mb-8">
<div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl">
<svg viewBox="0 0 128 128" className="w-10 h-10 dark:invert">
<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-neutral-900 dark:text-white">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-white">
Next.js Integration
</h1>
</div>
<div className="prose prose-neutral dark:prose-invert max-w-none">
<p className="lead text-xl text-neutral-600 dark:text-neutral-400">
<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-200 dark:border-neutral-800" />
<hr className="my-8 border-neutral-800" />
<h3>Using App Router (Recommended)</h3>
<p>

View File

@@ -93,10 +93,9 @@ export default function IntegrationsPage() {
<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 top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
<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.02] dark:opacity-[0.05]"
className="absolute inset-0 bg-grid-pattern opacity-[0.05]"
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
/>
</div>
@@ -110,14 +109,14 @@ export default function IntegrationsPage() {
>
{/* * --- Title with count badge --- */}
<div className="flex items-center justify-center gap-3 mb-6">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-white">
Integrations
</h1>
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold bg-brand-orange/10 text-brand-orange border border-brand-orange/20">
{integrations.length}+
</span>
</div>
<p className="text-xl text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto leading-relaxed mb-8">
<p className="text-xl text-neutral-400 max-w-2xl mx-auto leading-relaxed mb-8">
Connect Pulse with {integrations.length}+ frameworks and platforms in minutes.
</p>
@@ -144,12 +143,12 @@ export default function IntegrationsPage() {
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search integrations..."
className="w-full pl-12 pr-16 py-3 bg-white/70 dark:bg-neutral-900/70 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800 rounded-xl text-neutral-900 dark:text-white placeholder:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange/50 focus:border-brand-orange/50 transition-all"
className="w-full pl-12 pr-16 py-3 bg-neutral-900/70 backdrop-blur-sm border border-white/[0.08] rounded-xl text-white placeholder:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange/50 focus:border-brand-orange/50 transition-all"
/>
{query ? (
<button
onClick={() => setQuery('')}
className="absolute inset-y-0 right-0 flex items-center pr-4 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
className="absolute inset-y-0 right-0 flex items-center pr-4 text-neutral-400 hover:text-neutral-600 hover:text-neutral-300 transition-colors"
aria-label="Clear search"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
@@ -158,7 +157,7 @@ export default function IntegrationsPage() {
</button>
) : (
<div className="absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none">
<kbd className="hidden sm:inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono font-medium bg-neutral-200/80 dark:bg-neutral-700/80 text-neutral-500 dark:text-neutral-400 border border-neutral-300 dark:border-neutral-600">
<kbd className="hidden sm:inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono font-medium bg-neutral-700/80 text-neutral-400 border border-neutral-600">
/
</kbd>
</div>
@@ -170,7 +169,7 @@ export default function IntegrationsPage() {
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="text-sm text-neutral-500 dark:text-neutral-400 mt-3"
className="text-sm text-neutral-400 mt-3"
>
{totalResults} {totalResults === 1 ? 'integration' : 'integrations'} found
{query && <> for &ldquo;{query}&rdquo;</>}
@@ -190,7 +189,7 @@ export default function IntegrationsPage() {
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-all ${
activeCategory === 'all'
? 'bg-brand-orange text-white shadow-sm'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
: 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
}`}
>
All
@@ -202,7 +201,7 @@ export default function IntegrationsPage() {
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-all ${
activeCategory === cat
? 'bg-brand-orange text-white shadow-sm'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
: 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
}`}
>
{categoryLabels[cat]}
@@ -227,7 +226,7 @@ export default function IntegrationsPage() {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4 }}
className="text-lg font-semibold text-neutral-500 dark:text-neutral-400 mb-6 tracking-wide uppercase flex items-center gap-2"
className="text-lg font-semibold text-neutral-400 mb-6 tracking-wide uppercase flex items-center gap-2"
>
<svg className="w-5 h-5 text-brand-orange" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 1l2.39 4.84 5.34.78-3.87 3.77.91 5.33L10 13.27l-4.77 2.5.91-5.33L2.27 6.67l5.34-.78L10 1z" />
@@ -246,12 +245,12 @@ export default function IntegrationsPage() {
>
<Link
href={`/integrations/${integration!.id}`}
className="group flex items-center gap-3 p-4 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800 rounded-xl hover:border-brand-orange/50 dark:hover:border-brand-orange/50 transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg h-full"
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-100 dark:bg-neutral-800 rounded-lg shrink-0 group-hover:scale-110 transition-transform duration-300 [&_svg]:w-6 [&_svg]:h-6">
<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">
{integration!.icon}
</div>
<span className="font-semibold text-neutral-900 dark:text-white text-sm">
<span className="font-semibold text-white text-sm">
{integration!.name}
</span>
</Link>
@@ -269,7 +268,7 @@ export default function IntegrationsPage() {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4 }}
className="text-lg font-semibold text-neutral-500 dark:text-neutral-400 mb-6 tracking-wide uppercase"
className="text-lg font-semibold text-neutral-400 mb-6 tracking-wide uppercase"
>
{group.label}
</motion.h2>
@@ -285,19 +284,19 @@ export default function IntegrationsPage() {
>
<Link
href={`/integrations/${integration.id}`}
className="group relative p-6 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800 rounded-2xl hover:border-brand-orange/50 dark: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"
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">
<div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl group-hover:scale-110 transition-transform duration-300">
<div className="p-3 bg-neutral-800 rounded-xl group-hover:scale-110 transition-transform duration-300">
{integration.icon}
</div>
<ArrowRightIcon className="w-5 h-5 text-neutral-400 group-hover:text-brand-orange transition-colors" />
</div>
<h3 className="text-xl font-bold text-neutral-900 dark:text-white mb-3">
<h3 className="text-xl font-bold text-white mb-3">
{integration.name}
</h3>
<p className="text-neutral-600 dark:text-neutral-400 leading-relaxed mb-4">
<p className="text-neutral-400 leading-relaxed mb-4">
{integration.description}
</p>
<span className="text-sm font-medium text-brand-orange opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1">
@@ -318,20 +317,20 @@ export default function IntegrationsPage() {
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.3 }}
className="max-w-md mx-auto mt-8 p-10 border border-dashed border-neutral-300 dark:border-neutral-700 rounded-2xl flex flex-col items-center justify-center text-center"
className="max-w-md mx-auto mt-8 p-10 border border-dashed border-neutral-700 rounded-2xl flex flex-col items-center justify-center text-center"
>
<div className="p-4 bg-neutral-100 dark:bg-neutral-800 rounded-full mb-4">
<div className="p-4 bg-neutral-800 rounded-full mb-4">
<svg className="w-8 h-8 text-neutral-400" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
</div>
<h3 className="text-xl font-bold text-neutral-900 dark:text-white mb-2">
<h3 className="text-xl font-bold text-white mb-2">
Missing something?
</h3>
<p className="text-neutral-600 dark:text-neutral-400 text-sm mb-1">
<p className="text-neutral-400 text-sm mb-1">
No integrations found for &ldquo;{query}&rdquo;.
</p>
<p className="text-neutral-600 dark:text-neutral-400 text-sm mb-5">
<p className="text-neutral-400 text-sm mb-5">
Let us know which integration you&apos;d like to see next.
</p>
<a
@@ -351,12 +350,12 @@ export default function IntegrationsPage() {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="max-w-md mx-auto mt-12 p-6 border border-dashed border-neutral-300 dark:border-neutral-700 rounded-2xl flex flex-col items-center justify-center text-center"
className="max-w-md mx-auto mt-12 p-6 border border-dashed border-neutral-700 rounded-2xl flex flex-col items-center justify-center text-center"
>
<h3 className="text-xl font-bold text-neutral-900 dark:text-white mb-2">
<h3 className="text-xl font-bold text-white mb-2">
Missing something?
</h3>
<p className="text-neutral-600 dark:text-neutral-400 text-sm mb-4">
<p className="text-neutral-400 text-sm mb-4">
Let us know which integration you&apos;d like to see next.
</p>
<a

View File

@@ -8,10 +8,9 @@ export default function ReactIntegrationPage() {
<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 top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
<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.02] dark:opacity-[0.05]"
className="absolute inset-0 bg-grid-pattern opacity-[0.05]"
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
/>
</div>
@@ -26,23 +25,23 @@ export default function ReactIntegrationPage() {
</Link>
<div className="flex items-center gap-4 mb-8">
<div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl">
<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-neutral-900 dark:text-white">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-white">
React Integration
</h1>
</div>
<div className="prose prose-neutral dark:prose-invert max-w-none">
<p className="lead text-xl text-neutral-600 dark:text-neutral-400">
<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-200 dark:border-neutral-800" />
<hr className="my-8 border-neutral-800" />
<h3>Method 1: index.html (Recommended)</h3>
<p>

View File

@@ -8,10 +8,9 @@ export default function VueIntegrationPage() {
<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 top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
<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.02] dark:opacity-[0.05]"
className="absolute inset-0 bg-grid-pattern opacity-[0.05]"
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
/>
</div>
@@ -26,23 +25,23 @@ export default function VueIntegrationPage() {
</Link>
<div className="flex items-center gap-4 mb-8">
<div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl">
<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-neutral-900 dark:text-white">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-white">
Vue.js Integration
</h1>
</div>
<div className="prose prose-neutral dark:prose-invert max-w-none">
<p className="lead text-xl text-neutral-600 dark:text-neutral-400">
<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-200 dark:border-neutral-800" />
<hr className="my-8 border-neutral-800" />
<h3>Method 1: index.html (Recommended)</h3>
<p>

View File

@@ -8,10 +8,9 @@ export default function WordPressIntegrationPage() {
<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 top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
<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.02] dark:opacity-[0.05]"
className="absolute inset-0 bg-grid-pattern opacity-[0.05]"
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
/>
</div>
@@ -26,22 +25,22 @@ export default function WordPressIntegrationPage() {
</Link>
<div className="flex items-center gap-4 mb-8">
<div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl">
<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-neutral-900 dark:text-white">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-white">
WordPress Integration
</h1>
</div>
<div className="prose prose-neutral dark:prose-invert max-w-none">
<p className="lead text-xl text-neutral-600 dark:text-neutral-400">
<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-200 dark:border-neutral-800" />
<hr className="my-8 border-neutral-800" />
<h3>Method 1: Using a Plugin (Easiest)</h3>
<ol>

View File

@@ -3,6 +3,7 @@
import { OfflineBanner } from '@/components/OfflineBanner'
import { Footer } from '@/components/Footer'
import { Header, type CipheraApp } from '@ciphera-net/ui'
import { Header as MarketingHeader } from '@/components/marketing/Header'
import NotificationCenter from '@/components/notifications/NotificationCenter'
import { useAuth } from '@/lib/auth/context'
import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus'
@@ -144,31 +145,11 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
)
}
// Public/marketing: floating header + footer
// Public/marketing: sticky header + footer
return (
<div className="flex flex-col min-h-screen">
<Header
auth={auth}
LinkComponent={Link}
logoSrc="/pulse_icon_no_margins.png"
appName="Pulse"
variant="floating"
showFaq={false}
showSecurity={false}
showPricing={true}
topOffset={showOfflineBar ? '2.5rem' : undefined}
apps={CIPHERA_APPS}
currentAppId="pulse"
customNavItems={
<Link
href="/features"
className="px-4 py-2 text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50 transition-all duration-200"
>
Features
</Link>
}
/>
<main className="flex-1 pb-8 pt-24">
<MarketingHeader />
<main className="flex-1 pb-8">
{children}
</main>
<Footer

View File

@@ -1,4 +1,4 @@
import { ThemeProviders, Toaster } from '@ciphera-net/ui'
import { ThemeProvider, Toaster } from '@ciphera-net/ui'
import { AuthProvider } from '@/lib/auth/context'
import SWRProvider from '@/components/SWRProvider'
import type { Metadata, Viewport } from 'next'
@@ -45,15 +45,15 @@ export default function RootLayout({
children: React.ReactNode
}) {
return (
<html lang="en" className={plusJakartaSans.variable} suppressHydrationWarning>
<body className="antialiased min-h-screen flex flex-col bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-50">
<html lang="en" className={`${plusJakartaSans.variable} dark`} suppressHydrationWarning>
<body className="antialiased min-h-screen flex flex-col bg-neutral-950 text-neutral-100">
<SWRProvider>
<ThemeProviders>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}>
<AuthProvider>
<LayoutContent>{children}</LayoutContent>
<Toaster />
</AuthProvider>
</ThemeProviders>
</ThemeProvider>
</SWRProvider>
</body>
</html>

View File

@@ -6,23 +6,21 @@ export default function NotFound() {
<div className="relative min-h-[80vh] flex flex-col items-center justify-center overflow-hidden">
{/* * --- ATMOSPHERE (Background) --- */}
<div className="absolute inset-0 -z-10 pointer-events-none">
{/* * Center Orange Glow */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
{/* * Grid Pattern with Radial Mask */}
<div
className="absolute inset-0 bg-grid-pattern opacity-[0.02] dark:opacity-[0.05]"
className="absolute inset-0 bg-grid-pattern opacity-[0.05]"
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
/>
</div>
<div className="text-center px-4 z-10">
<h1 className="text-9xl font-bold text-transparent bg-clip-text bg-gradient-to-b from-neutral-900 to-neutral-500 dark:from-white dark:to-neutral-500 mb-4">
<h1 className="text-9xl font-bold text-transparent bg-clip-text bg-gradient-to-b from-white to-neutral-500 mb-4">
404
</h1>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-6">
<h2 className="text-2xl font-bold text-white mb-6">
Page not found
</h2>
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-md mx-auto mb-10 leading-relaxed">
<p className="text-lg text-neutral-400 max-w-md mx-auto mb-10 leading-relaxed">
Sorry, we couldn't find the page you're looking for. It might have been moved or deleted.
</p>

View File

@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'
import Link from 'next/link'
import { motion } from 'framer-motion'
import { useAuth } from '@/lib/auth/context'
import { initiateOAuthFlow, initiateSignupFlow } from '@/lib/api/oauth'
import { initiateOAuthFlow } from '@/lib/api/oauth'
import { listSites, listDeletedSites, restoreSite, type Site } from '@/lib/api/sites'
import { getStats } from '@/lib/api/stats'
import type { Stats } from '@/lib/api/stats'
@@ -13,102 +13,18 @@ import { LoadingOverlay } from '@ciphera-net/ui'
import SiteList from '@/components/sites/SiteList'
import DeleteSiteModal from '@/components/sites/DeleteSiteModal'
import { Button } from '@ciphera-net/ui'
import Image from 'next/image'
import { BarChartIcon, LockIcon, ZapIcon, CheckCircleIcon, XIcon, GlobeIcon } from '@ciphera-net/ui'
import { XIcon, GlobeIcon } from '@ciphera-net/ui'
import { Cookie, ShieldCheck, Code, Lightning, ArrowRight, GithubLogo } from '@phosphor-icons/react'
import DashboardDemo from '@/components/marketing/DashboardDemo'
import FeatureSections from '@/components/marketing/FeatureSections'
import ComparisonCards from '@/components/marketing/ComparisonCards'
import CTASection from '@/components/marketing/CTASection'
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'
function DashboardPreview() {
return (
<div className="relative w-full max-w-7xl mx-auto mt-20 mb-32">
<div className="absolute inset-0 bg-brand-orange/20 blur-[100px] -z-10 rounded-full opacity-50" />
<motion.div
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.4 }}
className="relative rounded-xl border border-neutral-200/50 dark:border-neutral-800/50 shadow-2xl overflow-hidden"
>
{/* * Browser chrome */}
<div className="h-8 bg-neutral-100 dark:bg-neutral-800/80 border-b border-neutral-200 dark:border-white/5 flex items-center px-4 gap-2">
<div className="w-3 h-3 rounded-full bg-red-400/60" />
<div className="w-3 h-3 rounded-full bg-yellow-400/60" />
<div className="w-3 h-3 rounded-full bg-green-400/60" />
<div className="ml-4 flex-1 max-w-xs h-5 rounded bg-neutral-200 dark:bg-neutral-700/50" />
</div>
{/* * Screenshot with bottom fade */}
<div className="relative max-h-[900px] overflow-hidden">
<Image
src="/dashboard-preview-v2.png"
alt="Pulse analytics dashboard showing visitor stats, charts, top pages, referrers, locations, and technology breakdown"
width={1920}
height={3000}
className="w-full h-auto object-cover object-top"
priority
/>
<div className="absolute inset-0 pointer-events-none bg-gradient-to-b from-transparent from-60% to-white dark:to-neutral-950" />
</div>
</motion.div>
</div>
)
}
function ComparisonSection() {
return (
<div className="w-full max-w-4xl mx-auto mb-32">
<div className="text-center mb-12">
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">Why choose Pulse?</h2>
<p className="text-neutral-500">The lightweight, privacy-friendly alternative.</p>
</div>
<div className="overflow-hidden rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-neutral-200 dark:border-neutral-800">
<th className="p-6 text-sm font-medium text-neutral-500">Feature</th>
<th className="p-6 text-sm font-bold text-brand-orange">Pulse</th>
<th className="p-6 text-sm font-medium text-neutral-500">Google Analytics</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{[
{ feature: "Cookie Banner Required", pulse: false, ga: true },
{ feature: "GDPR Compliant", pulse: true, ga: "Complex" },
{ feature: "Script Size", pulse: "< 1 KB", ga: "45 KB+" },
{ feature: "Data Ownership", pulse: "Yours", ga: "Google's" },
].map((row) => (
<tr key={row.feature} className="hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 transition-colors">
<td className="p-6 text-neutral-900 dark:text-white font-medium">{row.feature}</td>
<td className="p-6">
{row.pulse === true ? (
<CheckCircleIcon className="w-5 h-5 text-green-500" />
) : row.pulse === false ? (
<span className="text-green-500 font-medium">No</span>
) : (
<span className="text-green-500 font-medium">{row.pulse}</span>
)}
</td>
<td className="p-6 text-neutral-500">
{row.ga === true ? (
<span className="text-red-500 font-medium">Yes</span>
) : (
<span>{row.ga}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
type SiteStatsMap = Record<string, { stats: Stats }>
export default function HomePage() {
@@ -234,130 +150,81 @@ export default function HomePage() {
if (!user) {
return (
<div className="relative min-h-screen flex flex-col overflow-hidden">
{/* * --- 1. ATMOSPHERE (Background) --- */}
<div className="absolute inset-0 -z-10 pointer-events-none">
{/* * Top-left Orange Glow */}
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
{/* * Bottom-right Neutral Glow */}
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
{/* * Grid Pattern with Radial Mask */}
<div
className="absolute inset-0 bg-grid-pattern opacity-[0.02] dark:opacity-[0.05]"
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
/>
</div>
<div className="flex-grow w-full max-w-6xl mx-auto px-4 pt-20 pb-10 z-10">
{/* * --- 2. BADGE --- */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="inline-flex justify-center mb-8 w-full"
>
<span className="badge-primary">
<span className="w-1.5 h-1.5 rounded-full bg-brand-orange animate-pulse" />
Privacy-First Analytics
</span>
</motion.div>
{/* * --- 3. HEADLINE --- */}
<div className="text-center mb-20">
<>
{/* HERO — compact headline + live demo */}
<div className="pt-20 pb-10 lg:pt-28 lg:pb-16">
<div className="w-full max-w-6xl mx-auto px-6 text-center mb-16">
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="text-5xl md:text-7xl font-bold tracking-tight text-neutral-900 dark:text-white mb-6"
transition={{ duration: 0.5 }}
className="text-4xl sm:text-5xl md:text-6xl font-bold text-white leading-[1.1] mb-6"
>
Simple analytics for <br />
Analytics without the{' '}
<span className="relative inline-block">
<span className="gradient-text">privacy-conscious</span>
{/* * SVG Underline from Main Site */}
<span className="gradient-text">surveillance.</span>
<svg className="absolute -bottom-2 left-0 w-full h-3 text-brand-orange/30" viewBox="0 0 200 12" preserveAspectRatio="none">
<path d="M0 9C50 3 150 3 200 9" fill="none" stroke="currentColor" strokeWidth="4" strokeLinecap="round" />
</svg>
</span>
{' '}apps.
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="text-xl text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto mb-10 leading-relaxed"
transition={{ duration: 0.5, delay: 0.1 }}
className="text-xl text-neutral-300 mb-8 leading-relaxed max-w-2xl mx-auto"
>
Respect your users' privacy while getting the insights you need.
Respect your users&apos; privacy while getting the insights you need.
No cookies, no IP tracking, fully GDPR compliant.
</motion.p>
{/* * --- 4. CTAs --- */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="flex flex-row gap-3 flex-wrap justify-center mb-8"
>
<Button onClick={() => initiateOAuthFlow()} variant="primary" className="px-6 py-3 shadow-lg shadow-brand-orange/20 gap-2">
Try Pulse Free <ArrowRight weight="bold" className="w-4 h-4" />
</Button>
<Button onClick={() => window.open('https://github.com/ciphera-net/pulse', '_blank')} variant="secondary" className="px-6 py-3 border border-white/10 gap-2">
<GithubLogo weight="bold" className="w-4 h-4" /> View on GitHub
</Button>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-20"
className="flex flex-wrap gap-x-6 gap-y-3 text-sm text-neutral-400 justify-center"
>
<Button onClick={() => initiateOAuthFlow()} variant="primary" className="px-8 py-4 text-lg shadow-lg shadow-brand-orange/20">
Get Started
</Button>
<Button onClick={() => initiateSignupFlow()} variant="secondary" className="px-8 py-4 text-lg">
Create Account
</Button>
<span className="flex items-center gap-2"><Cookie weight="bold" className="w-4 h-4" /> Cookie-free</span>
<span className="text-neutral-700">|</span>
<span className="flex items-center gap-2"><Code weight="bold" className="w-4 h-4" /> Open source client</span>
<span className="text-neutral-700">|</span>
<span className="flex items-center gap-2"><ShieldCheck weight="bold" className="w-4 h-4" /> GDPR compliant</span>
<span className="text-neutral-700">|</span>
<span className="flex items-center gap-2"><Lightning weight="bold" className="w-4 h-4" /> Under 2KB</span>
</motion.div>
</div>
{/* * NEW: DASHBOARD PREVIEW */}
<DashboardPreview />
{/* * --- 5. GLASS CARDS --- */}
<div className="grid md:grid-cols-3 gap-6 text-left mb-32">
{[
{ icon: LockIcon, title: "Privacy First", desc: "We don't track personal data. No IP addresses, no fingerprints, no cookies." },
{ icon: BarChartIcon, title: "Simple Insights", desc: "Get the metrics that matter without the clutter. Page views, visitors, and sources." },
{ icon: ZapIcon, title: "Lightweight", desc: "Our script is less than 1kb. It won't slow down your site or affect your SEO." }
].map((feature, i) => (
<motion.div
key={feature.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.1 }}
className="card-glass p-6 hover:-translate-y-1 hover:shadow-xl transition-all duration-300 group"
>
<div className="w-12 h-12 rounded-xl bg-brand-orange/10 flex items-center justify-center mb-6 text-brand-orange group-hover:scale-110 transition-transform duration-300">
<feature.icon className="w-6 h-6" />
</div>
<h3 className="text-xl font-bold text-neutral-900 dark:text-white mb-3">{feature.title}</h3>
<p className="text-neutral-600 dark:text-neutral-400 leading-relaxed">
{feature.desc}
</p>
</motion.div>
))}
</div>
{/* * NEW: COMPARISON SECTION */}
<ComparisonSection />
{/* * NEW: CTA BOTTOM */}
{/* Live Dashboard Demo */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-center mb-20"
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.4 }}
className="w-full max-w-7xl mx-auto px-6"
>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-6">Ready to switch?</h2>
<Button onClick={() => initiateOAuthFlow()} variant="primary" className="px-8 py-4 text-lg shadow-lg shadow-brand-orange/20">
Start your free trial
</Button>
<p className="mt-4 text-sm text-neutral-500">No credit card required • Cancel anytime</p>
<DashboardDemo />
</motion.div>
</div>
</div>
<FeatureSections />
<ComparisonCards />
<PulseFAQ />
<CTASection />
</>
)
}
@@ -369,8 +236,8 @@ export default function HomePage() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
{showFinishSetupBanner && (
<div className="mb-6 flex items-center justify-between gap-4 rounded-2xl border border-brand-orange/30 bg-brand-orange/5 px-4 py-3 dark:bg-brand-orange/10">
<p className="text-sm text-neutral-700 dark:text-neutral-300">
<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">
Finish setting up your workspace and add your first site.
</p>
<div className="flex items-center gap-2 flex-shrink-0">
@@ -385,7 +252,7 @@ export default function HomePage() {
if (typeof window !== 'undefined') localStorage.setItem('pulse_welcome_completed', 'true')
setShowFinishSetupBanner(false)
}}
className="text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-400 p-1 rounded"
className="text-neutral-500 hover:text-neutral-400 p-1 rounded"
aria-label="Dismiss"
>
<XIcon className="h-4 w-4" />
@@ -396,8 +263,8 @@ export default function HomePage() {
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Your Sites</h1>
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Manage your analytics sites and view insights.</p>
<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>
</div>
{(() => {
const siteLimit = getSitesLimitForPlan(subscription?.plan_id)
@@ -405,7 +272,7 @@ export default function HomePage() {
return atLimit ? (
<div>
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 px-3 py-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700">
<span className="text-sm font-medium text-neutral-400 bg-neutral-800 px-3 py-1.5 rounded-lg border border-neutral-700">
Limit reached ({sites.length}/{siteLimit})
</span>
<Link href="/pricing">
@@ -415,7 +282,7 @@ export default function HomePage() {
</Link>
</div>
{deletedSites.length > 0 && (
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-2">
<p className="text-sm text-neutral-400 mt-2">
You have a site pending deletion. Restore it or permanently delete it to free the slot.
</p>
)}
@@ -432,26 +299,26 @@ export default function HomePage() {
{/* * 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-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<p className="text-sm text-neutral-500 dark:text-neutral-400">Total Sites</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-white">{sites.length}</p>
<div 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-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<p className="text-sm text-neutral-500 dark:text-neutral-400">Total Visitors (24h)</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-white">
<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-200 bg-brand-orange/10 p-4 dark:border-neutral-800">
<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/25 dark:bg-brand-orange/20" />
<div className="h-4 w-full rounded bg-brand-orange/25 dark:bg-brand-orange/20" />
<div className="h-4 w-3/4 rounded bg-brand-orange/25 dark:bg-brand-orange/20" />
<div className="h-4 w-20 rounded bg-brand-orange/25 dark:bg-brand-orange/20 pt-2" />
<div 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 ? (
<>
@@ -468,7 +335,7 @@ export default function HomePage() {
})()}
</p>
{(typeof subscription.sites_count === 'number' || (subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number') || (subscription.next_invoice_amount_due != null && subscription.next_invoice_currency && !subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing'))) && (
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
<p className="text-sm text-neutral-400 mt-1">
{typeof subscription.sites_count === 'number' && (
<span>Sites: {(() => {
const limit = getSitesLimitForPlan(subscription.plan_id)
@@ -516,12 +383,12 @@ export default function HomePage() {
</div>
{!sitesLoading && sites.length === 0 && (
<div className="mb-8 rounded-2xl border-2 border-dashed border-brand-orange/30 bg-brand-orange/5 p-6 text-center dark:bg-brand-orange/10">
<div className="mb-8 rounded-2xl border-2 border-dashed border-brand-orange/30 bg-brand-orange/10 p-6 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/20 text-brand-orange mb-4">
<GlobeIcon className="h-7 w-7" />
</div>
<h2 className="text-xl font-bold text-neutral-900 dark:text-white mb-2">Add your first site</h2>
<p className="text-neutral-600 dark:text-neutral-400 mb-6 max-w-md mx-auto">
<h2 className="text-xl font-bold text-white mb-2">Add your first site</h2>
<p className="text-neutral-400 mb-6 max-w-md mx-auto">
Connect a domain to start collecting privacy-friendly analytics. You can add more sites later from the dashboard.
</p>
<Link href="/sites/new">
@@ -557,31 +424,31 @@ export default function HomePage() {
{deletedSites.length > 0 && (
<div className="mt-8">
<h3 className="text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-4">Scheduled for Deletion</h3>
<h3 className="text-sm font-medium text-neutral-400 mb-4">Scheduled for Deletion</h3>
<div className="space-y-3">
{deletedSites.map((site) => {
const purgeAt = site.deleted_at ? new Date(new Date(site.deleted_at).getTime() + 7 * 24 * 60 * 60 * 1000) : null
const daysLeft = purgeAt ? Math.max(0, Math.ceil((purgeAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24))) : 0
return (
<div key={site.id} className="flex items-center justify-between p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 opacity-60">
<div key={site.id} className="flex items-center justify-between p-4 rounded-xl border border-neutral-800 bg-neutral-900/50 opacity-60">
<div>
<span className="font-medium text-neutral-700 dark:text-neutral-300">{site.name}</span>
<span className="font-medium text-neutral-300">{site.name}</span>
<span className="ml-2 text-sm text-neutral-400">{site.domain}</span>
<span className="ml-3 inline-flex items-center rounded-full bg-red-50 px-2 py-0.5 text-xs font-medium text-red-600 dark:bg-red-900/20 dark:text-red-400">
<span className="ml-3 inline-flex items-center rounded-full bg-red-900/20 px-2 py-0.5 text-xs font-medium text-red-400">
Deleting in {daysLeft} day{daysLeft !== 1 ? 's' : ''}
</span>
</div>
<div className="flex gap-2">
<button
onClick={() => handleRestore(site.id)}
className="px-3 py-1.5 text-xs font-medium text-neutral-700 dark:text-neutral-300 border border-neutral-300 dark:border-neutral-700 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
className="px-3 py-1.5 text-xs font-medium text-neutral-300 border border-neutral-700 rounded-lg hover:bg-neutral-800 transition-colors"
>
Restore
</button>
<button
onClick={() => handlePermanentDelete(site.id)}
className="px-3 py-1.5 text-xs font-medium text-red-600 dark:text-red-400 border border-red-200 dark:border-red-900 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
className="px-3 py-1.5 text-xs font-medium text-red-400 border border-red-900 rounded-lg hover:bg-red-900/20 transition-colors"
>
Delete Now
</button>

View File

@@ -19,8 +19,8 @@ export default function PricingPage() {
<Suspense fallback={
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-16">
<div className="text-center mb-12">
<div className="h-10 w-64 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mx-auto mb-4" />
<div className="h-5 w-96 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mx-auto" />
<div className="h-10 w-64 animate-pulse rounded bg-neutral-800 mx-auto mb-4" />
<div className="h-5 w-96 animate-pulse rounded bg-neutral-800 mx-auto" />
</div>
<PricingCardsSkeleton />
</div>

View File

@@ -1,9 +1,9 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import Image from 'next/image'
import { useParams, useSearchParams, useRouter } from 'next/navigation'
import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, type DashboardData, type Stats, type DailyStat } from '@/lib/api/stats'
import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, authenticatePublicDashboard, type DashboardData, type Stats, type DailyStat } from '@/lib/api/stats'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { ApiError } from '@/lib/api/client'
@@ -40,7 +40,9 @@ export default function PublicDashboardPage() {
const [data, setData] = useState<DashboardData | null>(null)
const [password, setPassword] = useState(passwordParam || '')
const [isPasswordProtected, setIsPasswordProtected] = useState(false)
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [authLoading, setAuthLoading] = useState(false)
// Captcha State
const [captchaId, setCaptchaId] = useState('')
const [captchaSolution, setCaptchaSolution] = useState('')
@@ -91,81 +93,42 @@ export default function PublicDashboardPage() {
const loadRealtime = useCallback(async () => {
try {
const auth = {
password,
captcha: {
captcha_id: captchaId,
captcha_solution: captchaSolution,
captcha_token: captchaToken
}
}
const realtimeData = await getPublicRealtime(siteId, auth)
const realtimeData = await getPublicRealtime(siteId)
if (data) {
setData({
...data,
realtime_visitors: realtimeData.visitors
})
setData({ ...data, realtime_visitors: realtimeData.visitors })
}
} catch (error) {
} catch {
// Silently fail for realtime updates
}
}, [siteId, password, captchaId, captchaSolution, captchaToken, data])
}, [siteId, data])
const loadDashboard = useCallback(async (silent = false) => {
try {
if (!silent) setLoading(true)
const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval
const auth = {
password,
captcha: {
captcha_id: captchaId,
captcha_solution: captchaSolution,
captcha_token: captchaToken
}
}
const [dashboardData, prevStatsData, prevDailyStatsData] = await Promise.all([
getPublicDashboard(
siteId,
dateRange.start,
dateRange.end,
10,
interval,
password,
auth.captcha
),
getPublicDashboard(siteId, dateRange.start, dateRange.end, 10, interval),
(async () => {
const prevRange = getPreviousDateRange(dateRange.start, dateRange.end)
return getPublicStats(siteId, prevRange.start, prevRange.end, auth)
return getPublicStats(siteId, prevRange.start, prevRange.end)
})(),
(async () => {
const prevRange = getPreviousDateRange(dateRange.start, dateRange.end)
return getPublicDailyStats(siteId, prevRange.start, prevRange.end, interval, auth)
return getPublicDailyStats(siteId, prevRange.start, prevRange.end, interval)
})()
])
setData(dashboardData)
setPrevStats(prevStatsData)
setPrevDailyStats(prevDailyStatsData)
setLastUpdatedAt(Date.now())
setIsPasswordProtected(false)
// Reset captcha
setCaptchaId('')
setCaptchaSolution('')
setCaptchaToken('')
} catch (error: unknown) {
const apiErr = error instanceof ApiError ? error : null
if (apiErr?.status === 401 && (apiErr.data as Record<string, unknown>)?.is_protected) {
setIsPasswordProtected(true)
if (password) {
toast.error('Invalid password or captcha')
// Reset captcha on failure
setCaptchaId('')
setCaptchaSolution('')
setCaptchaToken('')
}
} else if (apiErr?.status === 404) {
toast.error('Site not found')
} else if (!silent) {
@@ -174,7 +137,7 @@ export default function PublicDashboardPage() {
} finally {
if (!silent) setLoading(false)
}
}, [siteId, dateRange, todayInterval, multiDayInterval, password, captchaId, captchaSolution, captchaToken])
}, [siteId, dateRange, todayInterval, multiDayInterval])
// * Auto-refresh interval: chart, KPIs, and realtime count update every 30 seconds
useEffect(() => {
@@ -185,15 +148,36 @@ export default function PublicDashboardPage() {
}, 30000)
return () => clearInterval(interval)
}
}, [data, isPasswordProtected, dateRange, todayInterval, multiDayInterval, password, loadDashboard, loadRealtime])
}, [data, isPasswordProtected, dateRange, todayInterval, multiDayInterval, loadDashboard, loadRealtime])
useEffect(() => {
loadDashboard()
}, [siteId, dateRange, todayInterval, multiDayInterval, loadDashboard])
const handlePasswordSubmit = (e: React.FormEvent) => {
const handlePasswordSubmit = async (e: React.FormEvent) => {
e.preventDefault()
loadDashboard()
setAuthLoading(true)
try {
await authenticatePublicDashboard(siteId, password, captchaToken, captchaId, captchaSolution)
// Cookie is now set — load dashboard (cookie sent automatically)
setIsAuthenticated(true)
await loadDashboard()
} catch (error: unknown) {
const apiErr = error instanceof ApiError ? error : null
if (apiErr?.status === 401) {
const errData = apiErr.data as Record<string, unknown> | undefined
const errMsg = errData?.error as string | undefined
toast.error(errMsg || 'Invalid password or captcha')
} else {
toast.error('Authentication failed')
}
// Reset captcha on failure
setCaptchaId('')
setCaptchaSolution('')
setCaptchaToken('')
} finally {
setAuthLoading(false)
}
}
const showSkeleton = useMinimumLoading(loading && !data && !isPasswordProtected)

View File

@@ -216,16 +216,19 @@ export default function FunnelReportPage() {
</div>
{/* Chart */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden shadow-sm p-6 mb-8">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-6">
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden shadow-sm p-6 mb-8">
<h3 className="text-lg font-semibold text-white mb-6">
Funnel Visualization
</h3>
<FunnelChart
data={chartData}
orientation="vertical"
orientation="horizontal"
color="var(--chart-1)"
layers={3}
className="mx-auto max-w-md"
labelLayout="grouped"
labelAlign="center"
labelOrientation="vertical"
style={{ aspectRatio: '4 / 1' }}
/>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,19 @@
'use client'
import { useAuth } from '@/lib/auth/context'
import { useEffect, useState, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { motion, AnimatePresence } from 'framer-motion'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { useSite, useUptimeStatus } from '@/lib/swr/dashboard'
import { updateSite, type Site } from '@/lib/api/sites'
import {
createUptimeMonitor,
updateUptimeMonitor,
deleteUptimeMonitor,
getMonitorChecks,
type UptimeStatusResponse,
type MonitorStatus,
type UptimeCheck,
type UptimeDailyStat,
type CreateMonitorRequest,
} from '@/lib/api/uptime'
import { toast } from '@ciphera-net/ui'
import { useTheme } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { Button, Modal } from '@ciphera-net/ui'
import { Button } from '@ciphera-net/ui'
import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
import { formatDateFull, formatTime, formatDateTimeShort } from '@/lib/utils/formatDate'
import {
@@ -335,300 +329,65 @@ function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) {
)
}
// * Component: Monitor card (matches the reference image design)
function MonitorCard({
monitorStatus,
expanded,
onToggle,
onEdit,
onDelete,
canEdit,
siteId,
}: {
monitorStatus: MonitorStatus
expanded: boolean
onToggle: () => void
onEdit: () => void
onDelete: () => void
canEdit: boolean
siteId: string
}) {
const { monitor, daily_stats, overall_uptime } = monitorStatus
const [checks, setChecks] = useState<UptimeCheck[]>([])
const [loadingChecks, setLoadingChecks] = useState(false)
useEffect(() => {
if (expanded && checks.length === 0) {
const fetchChecks = async () => {
setLoadingChecks(true)
try {
const data = await getMonitorChecks(siteId, monitor.id, 50)
setChecks(data)
} catch {
// * Silent fail for check details
} finally {
setLoadingChecks(false)
}
}
fetchChecks()
}
}, [expanded, siteId, monitor.id, checks.length])
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden">
{/* Header */}
<button
onClick={onToggle}
className="w-full p-5 flex items-center justify-between hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors"
>
<div className="flex items-center gap-3">
{/* Status indicator */}
<div className={`w-3 h-3 rounded-full ${getStatusDotColor(monitor.last_status)} shrink-0`} />
<span className="font-semibold text-neutral-900 dark:text-white">
{monitor.name}
</span>
<span className="text-sm text-neutral-500 dark:text-neutral-400 hidden sm:inline">
{monitor.url}
</span>
</div>
<div className="flex items-center gap-4">
{monitor.last_response_time_ms !== null && (
<span className="text-sm text-neutral-500 dark:text-neutral-400 hidden sm:inline">
{formatMs(monitor.last_response_time_ms)}
</span>
)}
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
{formatUptime(overall_uptime)} uptime
</span>
<svg
className={`w-4 h-4 text-neutral-400 transition-transform ${expanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{/* Status bar */}
<div className="px-5 pb-4">
<UptimeStatusBar dailyStats={daily_stats} />
<div className="flex justify-between mt-1.5 text-xs text-neutral-400 dark:text-neutral-500">
<span>90 days ago</span>
<span>Today</span>
</div>
</div>
{/* Expanded details */}
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-5 pb-5 border-t border-neutral-200 dark:border-neutral-800 pt-4">
{/* Monitor details grid */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-5">
<div>
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1">
Status
</div>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${getStatusDotColor(monitor.last_status)}`} />
<span className="text-sm font-medium text-neutral-900 dark:text-white">
{getStatusLabel(monitor.last_status)}
</span>
</div>
</div>
<div>
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1">
Response Time
</div>
<span className="text-sm font-medium text-neutral-900 dark:text-white">
{formatMs(monitor.last_response_time_ms)}
</span>
</div>
<div>
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1">
Check Interval
</div>
<span className="text-sm font-medium text-neutral-900 dark:text-white">
{monitor.check_interval_seconds >= 60
? `${Math.floor(monitor.check_interval_seconds / 60)}m`
: `${monitor.check_interval_seconds}s`}
</span>
</div>
<div>
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1">
Last Checked
</div>
<span className="text-sm font-medium text-neutral-900 dark:text-white">
{formatTimeAgo(monitor.last_checked_at)}
</span>
</div>
</div>
{/* Response time chart */}
{loadingChecks ? (
<ChecksSkeleton />
) : checks.length > 0 ? (
<>
<ResponseTimeChart checks={checks} />
{/* Recent checks */}
<div className="mt-5">
<h4 className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-3">
Recent Checks
</h4>
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{checks.slice(0, 20).map((check) => (
<div
key={check.id}
className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-800 text-sm"
>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${getStatusDotColor(check.status)}`} />
<span className="text-neutral-600 dark:text-neutral-300 text-xs">
{formatDateTimeShort(new Date(check.checked_at))}
</span>
</div>
<div className="flex items-center gap-3">
{check.status_code && (
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{check.status_code}
</span>
)}
<span className="text-xs font-medium text-neutral-700 dark:text-neutral-300">
{formatMs(check.response_time_ms)}
</span>
</div>
</div>
))}
</div>
</div>
</>
) : null}
{/* Actions */}
{canEdit && (
<div className="flex gap-2 mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-800">
<Button
onClick={(e: React.MouseEvent) => { e.stopPropagation(); onEdit() }}
variant="secondary"
className="text-sm"
>
Edit
</Button>
<Button
onClick={(e: React.MouseEvent) => { e.stopPropagation(); onDelete() }}
variant="secondary"
className="text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
>
Delete
</Button>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
// * Main uptime page
export default function UptimePage() {
const { user } = useAuth()
const canEdit = user?.role === 'owner' || user?.role === 'admin'
const params = useParams()
const router = useRouter()
const siteId = params.id as string
const { data: site } = useSite(siteId)
const { data: site, mutate: mutateSite } = useSite(siteId)
const { data: uptimeData, isLoading, mutate: mutateUptime } = useUptimeStatus(siteId)
const [expandedMonitor, setExpandedMonitor] = useState<string | null>(null)
const [showAddModal, setShowAddModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [editingMonitor, setEditingMonitor] = useState<MonitorStatus | null>(null)
const [formData, setFormData] = useState<CreateMonitorRequest>({
name: '',
url: '',
check_interval_seconds: 300,
expected_status_code: 200,
timeout_seconds: 30,
})
const [saving, setSaving] = useState(false)
const [toggling, setToggling] = useState(false)
const [checks, setChecks] = useState<UptimeCheck[]>([])
const [loadingChecks, setLoadingChecks] = useState(false)
const handleAddMonitor = async () => {
if (!formData.name || !formData.url) {
toast.error('Name and URL are required')
// * Single monitor from the auto-managed uptime system
const monitor = uptimeData?.monitors?.[0] ?? null
const overallUptime = uptimeData?.overall_uptime ?? 100
const overallStatus = uptimeData?.status ?? 'operational'
// * Fetch recent checks when we have a monitor
useEffect(() => {
if (!monitor) {
setChecks([])
return
}
setSaving(true)
try {
await createUptimeMonitor(siteId, formData)
toast.success('Monitor created successfully')
setShowAddModal(false)
setFormData({ name: '', url: '', check_interval_seconds: 300, expected_status_code: 200, timeout_seconds: 30 })
mutateUptime()
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to create monitor')
} finally {
setSaving(false)
const fetchChecks = async () => {
setLoadingChecks(true)
try {
const data = await getMonitorChecks(siteId, monitor.monitor.id, 20)
setChecks(data)
} catch {
// * Silent fail for check details
} finally {
setLoadingChecks(false)
}
}
}
fetchChecks()
}, [siteId, monitor?.monitor.id])
const handleEditMonitor = async () => {
if (!editingMonitor || !formData.name || !formData.url) return
setSaving(true)
const handleToggleUptime = async (enabled: boolean) => {
if (!site) return
setToggling(true)
try {
await updateUptimeMonitor(siteId, editingMonitor.monitor.id, {
name: formData.name,
url: formData.url,
check_interval_seconds: formData.check_interval_seconds,
expected_status_code: formData.expected_status_code,
timeout_seconds: formData.timeout_seconds,
enabled: editingMonitor.monitor.enabled,
await updateSite(site.id, {
name: site.name,
timezone: site.timezone,
is_public: site.is_public,
excluded_paths: site.excluded_paths,
uptime_enabled: enabled,
})
toast.success('Monitor updated successfully')
setShowEditModal(false)
setEditingMonitor(null)
mutateSite()
mutateUptime()
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to update monitor')
toast.success(enabled ? 'Uptime monitoring enabled' : 'Uptime monitoring disabled')
} catch {
toast.error('Failed to update uptime monitoring')
} finally {
setSaving(false)
setToggling(false)
}
}
const handleDeleteMonitor = async (monitorId: string) => {
if (!window.confirm('Are you sure you want to delete this monitor? All historical data will be lost.')) return
try {
await deleteUptimeMonitor(siteId, monitorId)
toast.success('Monitor deleted')
mutateUptime()
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to delete monitor')
}
}
const openEditModal = (ms: MonitorStatus) => {
setEditingMonitor(ms)
setFormData({
name: ms.monitor.name,
url: ms.monitor.url,
check_interval_seconds: ms.monitor.check_interval_seconds,
expected_status_code: ms.monitor.expected_status_code,
timeout_seconds: ms.monitor.timeout_seconds,
})
setShowEditModal(true)
}
useEffect(() => {
if (site?.domain) document.title = `Uptime · ${site.domain} | Pulse`
}, [site?.domain])
@@ -639,10 +398,49 @@ export default function UptimePage() {
if (showSkeleton) return <UptimeSkeleton />
if (!site) return <div className="p-8 text-neutral-500">Site not found</div>
const monitors = Array.isArray(uptimeData?.monitors) ? uptimeData.monitors : []
const overallUptime = uptimeData?.overall_uptime ?? 100
const overallStatus = uptimeData?.status ?? 'operational'
const uptimeEnabled = site.uptime_enabled
// * Disabled state — show empty state with enable toggle
if (!uptimeEnabled) {
return (
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">
Uptime
</h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Monitor your site&apos;s availability and response time
</p>
</div>
{/* Empty state */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-12 text-center">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4 w-16 h-16 mx-auto mb-4 flex items-center justify-center">
<svg className="w-8 h-8 text-neutral-500 dark:text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 className="font-semibold text-neutral-900 dark:text-white mb-2">
Uptime monitoring is disabled
</h3>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6 max-w-md mx-auto">
Enable uptime monitoring to track your site&apos;s availability and response time around the clock.
</p>
{canEdit && (
<Button
onClick={() => handleToggleUptime(true)}
disabled={toggling}
>
{toggling ? 'Enabling...' : 'Enable Uptime Monitoring'}
</Button>
)}
</div>
</div>
)
}
// * Enabled state — show uptime dashboard
return (
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
{/* Header */}
@@ -652,327 +450,148 @@ export default function UptimePage() {
Uptime
</h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Monitor your endpoints and track availability over time
Monitor your site&apos;s availability and response time
</p>
</div>
{canEdit && (
<Button
onClick={() => {
setFormData({ name: '', url: '', check_interval_seconds: 300, expected_status_code: 200, timeout_seconds: 30 })
setShowAddModal(true)
}}
variant="secondary"
onClick={() => handleToggleUptime(false)}
disabled={toggling}
className="text-sm"
>
Add Monitor
{toggling ? 'Disabling...' : 'Disable Monitoring'}
</Button>
)}
</div>
{/* Overall status card */}
{monitors.length > 0 && (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-5 mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-3.5 h-3.5 rounded-full ${getStatusDotColor(overallStatus)}`} />
<div>
<span className="font-semibold text-neutral-900 dark:text-white text-lg">
{site.name}
</span>
<span className={`text-sm font-medium ml-3 ${getOverallStatusTextColor(overallStatus)}`}>
{getOverallStatusText(overallStatus)}
</span>
</div>
</div>
<div className="text-right">
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
{formatUptime(overallUptime)} uptime
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-5 mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-3.5 h-3.5 rounded-full ${getStatusDotColor(overallStatus)}`} />
<div>
<span className="font-semibold text-neutral-900 dark:text-white text-lg">
{site.name}
</span>
<span className={`text-sm font-medium ml-3 ${getOverallStatusTextColor(overallStatus)}`}>
{getOverallStatusText(overallStatus)}
</span>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{monitors.length} {monitors.length === 1 ? 'component' : 'components'}
</div>
</div>
</div>
</div>
)}
{/* Monitor list */}
{monitors.length > 0 ? (
<div className="space-y-4">
{monitors.map((ms) => (
<MonitorCard
key={ms.monitor.id}
monitorStatus={ms}
expanded={expandedMonitor === ms.monitor.id}
onToggle={() => setExpandedMonitor(
expandedMonitor === ms.monitor.id ? null : ms.monitor.id
)}
onEdit={() => openEditModal(ms)}
onDelete={() => handleDeleteMonitor(ms.monitor.id)}
canEdit={canEdit}
siteId={siteId}
/>
))}
</div>
) : (
/* Empty state */
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-12 text-center">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4 w-16 h-16 mx-auto mb-4 flex items-center justify-center">
<svg className="w-8 h-8 text-neutral-500 dark:text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 className="font-semibold text-neutral-900 dark:text-white mb-2">
No monitors yet
</h3>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6 max-w-md mx-auto">
Add a monitor to start tracking the uptime and response time of your endpoints. You can monitor APIs, websites, and any HTTP endpoint.
</p>
{canEdit && (
<Button
onClick={() => {
setFormData({ name: '', url: '', check_interval_seconds: 300, expected_status_code: 200, timeout_seconds: 30 })
setShowAddModal(true)
}}
>
Add Your First Monitor
</Button>
)}
</div>
)}
{/* Add Monitor Modal */}
<Modal isOpen={showAddModal} onClose={() => setShowAddModal(false)} title="Add Monitor">
<MonitorForm
formData={formData}
setFormData={setFormData}
onSubmit={handleAddMonitor}
onCancel={() => setShowAddModal(false)}
saving={saving}
submitLabel="Create Monitor"
siteDomain={site.domain}
/>
</Modal>
{/* Edit Monitor Modal */}
<Modal isOpen={showEditModal} onClose={() => setShowEditModal(false)} title="Edit Monitor">
<MonitorForm
formData={formData}
setFormData={setFormData}
onSubmit={handleEditMonitor}
onCancel={() => setShowEditModal(false)}
saving={saving}
submitLabel="Save Changes"
siteDomain={site.domain}
/>
</Modal>
</div>
)
}
// * Monitor creation/edit form
function MonitorForm({
formData,
setFormData,
onSubmit,
onCancel,
saving,
submitLabel,
siteDomain,
}: {
formData: CreateMonitorRequest
setFormData: (data: CreateMonitorRequest) => void
onSubmit: () => void
onCancel: () => void
saving: boolean
submitLabel: string
siteDomain: string
}) {
// * Derive protocol from formData.url so edit modal shows the monitor's actual scheme (no desync)
const protocol: 'https://' | 'http://' = formData.url.startsWith('http://') ? 'http://' : 'https://'
const [showProtocolDropdown, setShowProtocolDropdown] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
// * Extract the path portion from the full URL
const getPath = (): string => {
const url = formData.url
if (!url) return ''
try {
const parsed = new URL(url)
const pathAndRest = parsed.pathname + parsed.search + parsed.hash
return pathAndRest === '/' ? '' : pathAndRest
} catch {
// ? If not a valid full URL, try stripping the protocol prefix
if (url.startsWith('https://')) return url.slice(8 + siteDomain.length)
if (url.startsWith('http://')) return url.slice(7 + siteDomain.length)
return url
}
}
const handlePathChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const path = e.target.value
const safePath = path.startsWith('/') || path === '' ? path : `/${path}`
setFormData({ ...formData, url: `${protocol}${siteDomain}${safePath}` })
}
const handleProtocolChange = (proto: 'https://' | 'http://') => {
setShowProtocolDropdown(false)
const path = getPath()
setFormData({ ...formData, url: `${proto}${siteDomain}${path}` })
}
// * Initialize URL if empty
useEffect(() => {
if (!formData.url) {
setFormData({ ...formData, url: `${protocol}${siteDomain}` })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// * Close dropdown on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setShowProtocolDropdown(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
return (
<div className="space-y-4">
{/* Name */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Name
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g. API, Website, CDN"
autoFocus
maxLength={100}
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:border-transparent text-sm"
/>
{formData.name.length > 80 && (
<span className={`text-xs tabular-nums mt-1 ${formData.name.length > 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{formData.name.length}/100</span>
)}
</div>
{/* URL with protocol dropdown + domain prefix */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
URL
</label>
<div className="flex rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 focus-within:ring-2 focus-within:ring-brand-orange focus-within:border-transparent overflow-hidden">
{/* Protocol dropdown */}
<div ref={dropdownRef} className="relative">
<button
type="button"
onClick={() => setShowProtocolDropdown(!showProtocolDropdown)}
className="h-full px-3 flex items-center gap-1 bg-neutral-100 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-300 text-sm border-r border-neutral-300 dark:border-neutral-600 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors select-none whitespace-nowrap"
>
{protocol}
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{showProtocolDropdown && (
<div className="absolute top-full left-0 mt-1 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg transition-shadow duration-300 z-10 min-w-[100px]">
<button
type="button"
onClick={() => handleProtocolChange('https://')}
className={`w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors rounded-t-lg ${protocol === 'https://' ? 'text-brand-orange font-medium' : 'text-neutral-700 dark:text-neutral-300'}`}
>
https://
</button>
<button
type="button"
onClick={() => handleProtocolChange('http://')}
className={`w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors rounded-b-lg ${protocol === 'http://' ? 'text-brand-orange font-medium' : 'text-neutral-700 dark:text-neutral-300'}`}
>
http://
</button>
<div className="text-right">
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
{formatUptime(overallUptime)} uptime
</span>
{monitor && (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
Last checked {formatTimeAgo(monitor.monitor.last_checked_at)}
</div>
)}
</div>
{/* Domain prefix */}
<span className="flex items-center px-1.5 text-sm text-neutral-500 dark:text-neutral-400 select-none whitespace-nowrap bg-neutral-100 dark:bg-neutral-700 border-r border-neutral-300 dark:border-neutral-600">
{siteDomain}
</span>
{/* Path input */}
<input
type="text"
value={getPath()}
onChange={handlePathChange}
placeholder="/api/health"
className="flex-1 min-w-0 px-3 py-2 bg-transparent text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none text-sm"
/>
</div>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Add a specific path (e.g. /api/health) or leave empty for the root domain
</p>
</div>
{/* Check interval */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Check Interval
</label>
<select
value={formData.check_interval_seconds}
onChange={(e) => setFormData({ ...formData, check_interval_seconds: parseInt(e.target.value) })}
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange focus:border-transparent text-sm"
>
<option value={60}>Every 1 minute</option>
<option value={120}>Every 2 minutes</option>
<option value={300}>Every 5 minutes</option>
<option value={600}>Every 10 minutes</option>
<option value={900}>Every 15 minutes</option>
<option value={1800}>Every 30 minutes</option>
<option value={3600}>Every 1 hour</option>
</select>
</div>
{/* 90-day uptime bar */}
{monitor && (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-5 mb-6">
<h3 className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-3">
90-Day Availability
</h3>
<UptimeStatusBar dailyStats={monitor.daily_stats} />
<div className="flex justify-between mt-1.5 text-xs text-neutral-400 dark:text-neutral-500">
<span>90 days ago</span>
<span>Today</span>
</div>
</div>
)}
{/* Expected status code */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Expected Status Code
</label>
<input
type="number"
value={formData.expected_status_code}
onChange={(e) => setFormData({ ...formData, expected_status_code: parseInt(e.target.value) || 200 })}
min={100}
max={599}
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange focus:border-transparent text-sm [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
{/* Response time chart + Recent checks */}
{monitor && (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-5">
{/* Monitor details grid */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-5">
<div>
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1">
Status
</div>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${getStatusDotColor(monitor.monitor.last_status)}`} />
<span className="text-sm font-medium text-neutral-900 dark:text-white">
{getStatusLabel(monitor.monitor.last_status)}
</span>
</div>
</div>
<div>
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1">
Response Time
</div>
<span className="text-sm font-medium text-neutral-900 dark:text-white">
{formatMs(monitor.monitor.last_response_time_ms)}
</span>
</div>
<div>
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1">
Check Interval
</div>
<span className="text-sm font-medium text-neutral-900 dark:text-white">
{monitor.monitor.check_interval_seconds >= 60
? `${Math.floor(monitor.monitor.check_interval_seconds / 60)}m`
: `${monitor.monitor.check_interval_seconds}s`}
</span>
</div>
<div>
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1">
Overall Uptime
</div>
<span className="text-sm font-medium text-neutral-900 dark:text-white">
{formatUptime(monitor.overall_uptime)}
</span>
</div>
</div>
{/* Timeout */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Timeout (seconds)
</label>
<input
type="number"
value={formData.timeout_seconds}
onChange={(e) => setFormData({ ...formData, timeout_seconds: parseInt(e.target.value) || 30 })}
min={5}
max={60}
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange focus:border-transparent text-sm [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
{/* Response time chart */}
{loadingChecks ? (
<ChecksSkeleton />
) : checks.length > 0 ? (
<>
<ResponseTimeChart checks={checks} />
{/* Actions */}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onCancel}>
Cancel
</Button>
<Button onClick={onSubmit} disabled={saving || !formData.name || !formData.url}>
{saving ? 'Saving...' : submitLabel}
</Button>
</div>
{/* Recent checks */}
<div className="mt-5">
<h4 className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-3">
Recent Checks
</h4>
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{checks.slice(0, 20).map((check) => (
<div
key={check.id}
className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-800 text-sm"
>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${getStatusDotColor(check.status)}`} />
<span className="text-neutral-600 dark:text-neutral-300 text-xs">
{formatDateTimeShort(new Date(check.checked_at))}
</span>
</div>
<div className="flex items-center gap-3">
{check.status_code && (
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{check.status_code}
</span>
)}
<span className="text-xs font-medium text-neutral-700 dark:text-neutral-300">
{formatMs(check.response_time_ms)}
</span>
</div>
</div>
))}
</div>
</div>
</>
) : null}
</div>
)}
</div>
)
}

View File

@@ -39,10 +39,9 @@ export function IntegrationGuide({ integration, children }: IntegrationGuideProp
<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 top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
<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.02] dark:opacity-[0.05]"
className="absolute inset-0 bg-grid-pattern opacity-[0.05]"
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
/>
</div>
@@ -57,18 +56,18 @@ export function IntegrationGuide({ integration, children }: IntegrationGuideProp
</Link>
<div className="flex items-center gap-4 mb-8">
<div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl">
<div className="p-3 bg-neutral-800 rounded-xl">
{headerIcon}
</div>
<h1 className="text-4xl md:text-5xl font-bold text-neutral-900 dark:text-white">
<h1 className="text-4xl md:text-5xl font-bold text-white">
{integration.name} Integration
</h1>
</div>
<div className="prose prose-neutral dark:prose-invert max-w-none">
<div className="prose prose-invert max-w-none">
{children}
<hr className="my-8 border-neutral-200 dark:border-neutral-800" />
<hr className="my-8 border-neutral-800" />
<h3>Optional: Frustration Tracking</h3>
<p>
Detect rage clicks and dead clicks by adding the frustration tracking
@@ -83,8 +82,8 @@ export function IntegrationGuide({ integration, children }: IntegrationGuideProp
{/* * --- Related Integrations --- */}
{relatedIntegrations.length > 0 && (
<div className="mt-16 pt-10 border-t border-neutral-200 dark:border-neutral-800">
<h2 className="text-xl font-bold text-neutral-900 dark:text-white mb-6">
<div className="mt-16 pt-10 border-t border-neutral-800">
<h2 className="text-xl font-bold text-white mb-6">
Related Integrations
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -92,16 +91,16 @@ export function IntegrationGuide({ integration, children }: IntegrationGuideProp
<Link
key={related.id}
href={`/integrations/${related.id}`}
className="group flex items-center gap-4 p-4 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800 rounded-xl hover:border-brand-orange/50 dark:hover:border-brand-orange/50 transition-all duration-300"
className="group flex items-center gap-4 p-4 bg-neutral-900/50 backdrop-blur-sm border border-neutral-800 rounded-xl hover:border-brand-orange/50 transition-all duration-300"
>
<div className="p-2 bg-neutral-100 dark:bg-neutral-800 rounded-lg shrink-0 [&_svg]:w-6 [&_svg]:h-6">
<div className="p-2 bg-neutral-800 rounded-lg shrink-0 [&_svg]:w-6 [&_svg]:h-6">
{related.icon}
</div>
<div className="min-w-0 flex-1">
<span className="font-semibold text-neutral-900 dark:text-white block">
<span className="font-semibold text-white block">
{related.name}
</span>
<span className="text-sm text-neutral-500 dark:text-neutral-400 truncate block">
<span className="text-sm text-neutral-400 truncate block">
{related.description}
</span>
</div>

View File

@@ -219,10 +219,10 @@ export default function PricingSection() {
transition={{ duration: 0.5 }}
className="text-center mb-12"
>
<h2 className="text-3xl font-bold text-neutral-900 dark:text-white mb-4">
<h2 className="text-3xl font-bold text-white mb-4">
Transparent Pricing
</h2>
<p className="text-lg text-neutral-600 dark:text-neutral-400">
<p className="text-lg text-neutral-400">
Scale with your traffic. No hidden fees.
</p>
</motion.div>
@@ -232,13 +232,13 @@ export default function PricingSection() {
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="max-w-6xl mx-auto border border-neutral-200 dark:border-neutral-800 rounded-2xl bg-white/50 dark:bg-neutral-900/50 backdrop-blur-xl shadow-sm overflow-hidden mb-20"
className="max-w-6xl mx-auto border border-neutral-800 rounded-2xl bg-neutral-900/50 backdrop-blur-xl shadow-sm overflow-hidden mb-20"
>
{/* Top Toolbar */}
<div className="p-6 border-b border-neutral-200 dark:border-neutral-800 flex flex-col md:flex-row items-center justify-between gap-8 bg-neutral-50/50 dark:bg-neutral-900/50">
<div className="p-6 border-b border-neutral-800 flex flex-col md:flex-row items-center justify-between gap-8 bg-neutral-900/50">
<div className="w-full md:w-2/3">
<div className="flex justify-between text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-4">
<div className="flex justify-between text-sm font-medium text-neutral-400 mb-4">
<span>10k</span>
<span className="text-brand-orange font-bold text-lg">
Up to {currentTraffic.label} monthly pageviews
@@ -254,23 +254,23 @@ export default function PricingSection() {
onChange={(e) => setSliderIndex(parseInt(e.target.value))}
aria-label="Monthly pageview limit"
aria-valuetext={`${currentTraffic.label} pageviews per month`}
className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-neutral-700 accent-brand-orange focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
className="w-full h-2 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-brand-orange focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
/>
</div>
<div className="flex flex-col items-end gap-2 shrink-0">
<span className="text-xs text-neutral-500 dark:text-neutral-400 font-medium uppercase tracking-wide">
<span className="text-xs text-neutral-400 font-medium uppercase tracking-wide">
Get 1 month free with yearly
</span>
<div className="bg-neutral-200 dark:bg-neutral-800 p-1 rounded-lg flex" role="radiogroup" aria-label="Billing interval">
<div className="bg-neutral-800 p-1 rounded-lg flex" role="radiogroup" aria-label="Billing interval">
<button
onClick={() => setIsYearly(false)}
role="radio"
aria-checked={!isYearly}
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange ${
!isYearly
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-900 dark:hover:text-white'
? 'bg-neutral-700 text-white shadow-sm'
: 'text-neutral-500 hover:text-white'
}`}
>
Monthly
@@ -281,8 +281,8 @@ export default function PricingSection() {
aria-checked={isYearly}
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange ${
isYearly
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-900 dark:hover:text-white'
? 'bg-neutral-700 text-white shadow-sm'
: 'text-neutral-500 hover:text-white'
}`}
>
Yearly
@@ -292,15 +292,15 @@ export default function PricingSection() {
</div>
{/* Pricing Grid */}
<div className="grid md:grid-cols-5 divide-y md:divide-y-0 md:divide-x divide-neutral-200 dark:divide-neutral-800">
<div className="grid md:grid-cols-5 divide-y md:divide-y-0 md:divide-x divide-neutral-800">
{/* Free Plan */}
<div className="p-6 flex flex-col relative transition-colors hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50">
<div className="p-6 flex flex-col relative transition-colors hover:bg-neutral-800/50">
<div className="mb-8">
<h3 className="text-lg font-bold text-neutral-900 dark:text-white mb-2">Free</h3>
<p className="text-sm text-neutral-500 dark:text-neutral-400 min-h-[40px] mb-4">For trying Pulse on a personal project</p>
<h3 className="text-lg font-bold text-white mb-2">Free</h3>
<p className="text-sm text-neutral-400 min-h-[40px] mb-4">For trying Pulse on a personal project</p>
<div className="flex items-baseline gap-1">
<span className="text-4xl font-bold text-neutral-900 dark:text-white">0</span>
<span className="text-neutral-500 dark:text-neutral-400 font-medium">/forever</span>
<span className="text-4xl font-bold text-white">0</span>
<span className="text-neutral-400 font-medium">/forever</span>
</div>
</div>
@@ -320,7 +320,7 @@ export default function PricingSection() {
<ul className="space-y-4 flex-grow">
{['1 site', '5k monthly pageviews', '6 months data retention', '100% Data ownership'].map((feature) => (
<li key={feature} className="flex items-start gap-3 text-sm text-neutral-600 dark:text-neutral-400">
<li key={feature} className="flex items-start gap-3 text-sm text-neutral-400">
<CheckCircleIcon className="w-5 h-5 shrink-0 text-neutral-400" />
<span>{feature}</span>
</li>
@@ -333,7 +333,7 @@ export default function PricingSection() {
const isTeam = plan.id === 'team'
return (
<div key={plan.id} className={`p-6 flex flex-col relative transition-colors ${isTeam ? 'bg-brand-orange/[0.02]' : 'hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50'}`}>
<div key={plan.id} className={`p-6 flex flex-col relative transition-colors ${isTeam ? 'bg-brand-orange/[0.02]' : 'hover:bg-neutral-800/50'}`}>
{isTeam && (
<>
<div className="absolute top-0 left-0 w-full h-1 bg-brand-orange" />
@@ -344,17 +344,17 @@ export default function PricingSection() {
)}
<div className="mb-8">
<h3 className="text-lg font-bold text-neutral-900 dark:text-white mb-2">{plan.name}</h3>
<p className="text-sm text-neutral-500 dark:text-neutral-400 min-h-[40px] mb-4">{plan.description}</p>
<h3 className="text-lg font-bold text-white mb-2">{plan.name}</h3>
<p className="text-sm text-neutral-400 min-h-[40px] mb-4">{plan.description}</p>
{priceDetails ? (
isYearly ? (
<div>
<div className="flex items-baseline gap-1">
<span className="text-4xl font-bold text-neutral-900 dark:text-white">
<span className="text-4xl font-bold text-white">
{priceDetails.yearlyTotal}
</span>
<span className="text-neutral-500 dark:text-neutral-400 font-medium">/year</span>
<span className="text-neutral-400 font-medium">/year</span>
</div>
<div className="flex items-center gap-2 mt-2 text-sm font-medium">
<span className="text-neutral-400 line-through decoration-neutral-400">
@@ -367,14 +367,14 @@ export default function PricingSection() {
</div>
) : (
<div className="flex items-baseline gap-1">
<span className="text-4xl font-bold text-neutral-900 dark:text-white">
<span className="text-4xl font-bold text-white">
{priceDetails.baseMonthly}
</span>
<span className="text-neutral-500 dark:text-neutral-400 font-medium">/mo</span>
<span className="text-neutral-400 font-medium">/mo</span>
</div>
)
) : (
<div className="text-4xl font-bold text-neutral-900 dark:text-white">
<div className="text-4xl font-bold text-white">
Custom
</div>
)}
@@ -391,7 +391,7 @@ export default function PricingSection() {
<ul className="space-y-4 flex-grow">
{plan.features.map((feature) => (
<li key={feature} className="flex items-start gap-3 text-sm text-neutral-600 dark:text-neutral-400">
<li key={feature} className="flex items-start gap-3 text-sm text-neutral-400">
<CheckCircleIcon className={`w-5 h-5 shrink-0 ${isTeam ? 'text-brand-orange' : 'text-neutral-400'}`} />
<span>{feature}</span>
</li>
@@ -402,11 +402,11 @@ export default function PricingSection() {
})}
{/* Enterprise Section */}
<div className="p-6 bg-neutral-50/50 dark:bg-neutral-900/50 flex flex-col">
<div className="p-6 bg-neutral-900/50 flex flex-col">
<div className="mb-8">
<h3 className="text-lg font-bold text-neutral-900 dark:text-white mb-2">Enterprise</h3>
<p className="text-sm text-neutral-500 dark:text-neutral-400 min-h-[40px] mb-4">For high volume sites and custom needs</p>
<div className="text-4xl font-bold text-neutral-900 dark:text-white">
<h3 className="text-lg font-bold text-white mb-2">Enterprise</h3>
<p className="text-sm text-neutral-400 min-h-[40px] mb-4">For high volume sites and custom needs</p>
<div className="text-4xl font-bold text-white">
Custom
</div>
</div>
@@ -428,7 +428,7 @@ export default function PricingSection() {
'Managed Proxy',
'Raw data export'
].map((feature) => (
<li key={feature} className="flex items-start gap-3 text-sm text-neutral-600 dark:text-neutral-400">
<li key={feature} className="flex items-start gap-3 text-sm text-neutral-400">
<CheckCircleIcon className="w-5 h-5 text-neutral-400 shrink-0" />
<span>{feature}</span>
</li>

View File

@@ -111,7 +111,7 @@ export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSugg
className={`inline-flex items-center gap-2 px-3 py-1.5 text-xs font-medium rounded-lg transition-all cursor-pointer ${
isOpen
? 'bg-brand-orange/10 text-brand-orange border border-brand-orange/30'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700 hover:text-neutral-900 dark:hover:text-white border border-transparent'
: 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700 hover:text-white border border-transparent'
}`}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -121,7 +121,7 @@ export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSugg
</button>
{isOpen && (
<div className="absolute top-full left-0 mt-1.5 z-50 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden min-w-[280px]">
<div className="absolute top-full left-0 mt-1.5 z-50 bg-neutral-900 border border-neutral-700 rounded-xl shadow-xl overflow-hidden min-w-[280px]">
{!selectedDim ? (
/* Step 1: Dimension list */
<div className="py-1">
@@ -129,9 +129,9 @@ export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSugg
<button
key={dim}
onClick={() => setSelectedDim(dim)}
className="w-full flex items-center justify-between px-4 py-2.5 text-sm text-left hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors cursor-pointer"
className="w-full flex items-center justify-between px-4 py-2.5 text-sm text-left hover:bg-neutral-800 transition-colors cursor-pointer"
>
<span className="text-neutral-900 dark:text-white font-medium">{DIMENSION_LABELS[dim]}</span>
<span className="text-white font-medium">{DIMENSION_LABELS[dim]}</span>
<svg className="w-3.5 h-3.5 text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
@@ -145,13 +145,13 @@ export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSugg
<div className="flex items-center gap-2 px-3 pt-3 pb-2">
<button
onClick={() => { setSelectedDim(null); setSearch(''); setOperator('is'); setFetchedSuggestions([]) }}
className="p-1 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors cursor-pointer rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-800"
className="p-1 text-neutral-400 hover:text-neutral-300 transition-colors cursor-pointer rounded-md hover:bg-neutral-800"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
<span className="text-sm font-semibold text-white">
{DIMENSION_LABELS[selectedDim]}
</span>
</div>
@@ -165,7 +165,7 @@ export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSugg
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors cursor-pointer ${
operator === op
? 'bg-brand-orange text-white'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
: 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
}`}
>
{OPERATOR_LABELS[op]}
@@ -189,24 +189,24 @@ export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSugg
}
}}
placeholder={`Search ${DIMENSION_LABELS[selectedDim]?.toLowerCase()}...`}
className="w-full px-3 py-2 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange/40 focus:border-brand-orange transition-colors"
className="w-full px-3 py-2 text-sm bg-neutral-800 border border-neutral-700 rounded-lg text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange/40 focus:border-brand-orange transition-colors"
/>
</div>
{/* Values list */}
{isFetching ? (
<div className="px-4 py-6 text-center">
<div className="inline-block w-4 h-4 border-2 border-neutral-300 dark:border-neutral-600 border-t-brand-orange rounded-full animate-spin" />
<div className="inline-block w-4 h-4 border-2 border-neutral-600 border-t-brand-orange rounded-full animate-spin" />
</div>
) : filtered.length > 0 ? (
<div className="max-h-52 overflow-y-auto border-t border-neutral-100 dark:border-neutral-800">
<div className="max-h-52 overflow-y-auto border-t border-neutral-800">
{filtered.map(s => (
<button
key={s.value}
onClick={() => handleSelectValue(s.value)}
className="w-full flex items-center justify-between px-4 py-2 text-sm text-left hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors cursor-pointer"
className="w-full flex items-center justify-between px-4 py-2 text-sm text-left hover:bg-neutral-800 transition-colors cursor-pointer"
>
<span className="truncate text-neutral-900 dark:text-white">{s.label}</span>
<span className="truncate text-white">{s.label}</span>
{s.count !== undefined && (
<span className="text-xs text-neutral-400 dark:text-neutral-500 ml-2 tabular-nums flex-shrink-0">
{s.count.toLocaleString()}
@@ -216,7 +216,7 @@ export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSugg
))}
</div>
) : search.trim() ? (
<div className="px-3 py-3 border-t border-neutral-100 dark:border-neutral-800">
<div className="px-3 py-3 border-t border-neutral-800">
<button
onClick={handleSubmitCustom}
className="w-full px-3 py-2 text-sm font-medium bg-brand-orange text-white rounded-lg hover:bg-brand-orange/90 transition-colors cursor-pointer"

View File

@@ -124,17 +124,17 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
return (
<>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="bg-neutral-900/80 border border-white/[0.08] rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Megaphone className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
<h3 className="text-lg font-semibold text-white">
Campaigns
</h3>
{showViewAll && (
<button
onClick={() => setIsModalOpen(true)}
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
aria-label="View all campaigns"
>
<FrameCornersIcon className="w-4 h-4" weight="bold" />
@@ -161,13 +161,13 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
<div
key={`${item.source}|${item.medium}|${item.campaign}`}
onClick={() => onFilter?.({ dimension: 'utm_source', operator: 'is', values: [item.source] })}
className={`relative flex items-center justify-between py-1.5 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
className={`relative flex items-center justify-between py-1.5 group hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
>
<div
className="absolute inset-y-0.5 left-0.5 bg-brand-orange/15 dark:bg-brand-orange/40 rounded-md transition-all"
style={{ width: `${barWidth}%` }}
/>
<div className="relative flex-1 text-neutral-900 dark:text-white flex items-center gap-3 min-w-0">
<div className="relative flex-1 text-white flex items-center gap-3 min-w-0">
{renderSourceIcon(item.source)}
<div className="min-w-0">
<div className="truncate font-medium text-sm" title={item.source}>
@@ -184,7 +184,7 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{totalVisitors > 0 ? `${Math.round((item.visitors / totalVisitors) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
<span className="text-sm font-semibold text-neutral-400">
{formatNumber(item.visitors)}
</span>
</div>
@@ -197,13 +197,13 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<Megaphone className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
<div className="rounded-full bg-neutral-800 p-4">
<Megaphone className="w-8 h-8 text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
<h4 className="font-semibold text-white">
Track your marketing campaigns
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
<p className="text-sm text-neutral-400 max-w-xs">
Add UTM parameters to your links to see campaign performance here.
</p>
<button
@@ -230,7 +230,7 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
value={modalSearch}
onChange={(e) => setModalSearch(e.target.value)}
placeholder="Search campaigns..."
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-800 border border-neutral-700 rounded-lg text-white placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
/>
</div>
<div className="max-h-[80vh]">
@@ -262,12 +262,12 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
<div
key={`${item.source}|${item.medium}|${item.campaign}`}
onClick={() => { if (onFilter) { onFilter({ dimension: 'utm_source', operator: 'is', values: [item.source] }); setIsModalOpen(false) } }}
className={`flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
className={`flex items-center justify-between py-2 group hover:bg-neutral-800 rounded-lg px-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
>
<div className="flex-1 flex items-center gap-3 min-w-0">
{renderSourceIcon(item.source)}
<div className="min-w-0">
<div className="text-neutral-900 dark:text-white font-medium truncate text-sm" title={item.source}>
<div className="text-white font-medium truncate text-sm" title={item.source}>
{getReferrerDisplayName(item.source)}
</div>
<div className="flex items-center gap-1.5 text-[11px] text-neutral-400 dark:text-neutral-500">
@@ -281,7 +281,7 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{modalTotal > 0 ? `${Math.round((item.visitors / modalTotal) * 100)}%` : ''}
</span>
<span className="font-semibold text-neutral-900 dark:text-white">
<span className="font-semibold text-white">
{formatNumber(item.visitors)}
</span>
<span className="text-neutral-400 dark:text-neutral-500 w-16 text-right">

View File

@@ -2,8 +2,7 @@
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
import { useTheme } from '@ciphera-net/ui'
import { Area, CartesianGrid, ComposedChart, Line, XAxis, YAxis, ReferenceLine } from 'recharts'
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/line-charts-6'
import { AreaChart as VisxAreaChart, Area as VisxArea, Grid as VisxGrid, XAxis as VisxXAxis, YAxis as VisxYAxis, ChartTooltip as VisxChartTooltip, type TooltipRow } from '@/components/ui/area-chart'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { formatNumber, formatDuration, formatUpdatedAgo, DatePicker } from '@ciphera-net/ui'
import { Select, DownloadIcon, PlusIcon, XIcon } from '@ciphera-net/ui'
@@ -103,40 +102,11 @@ const METRIC_CONFIGS: {
{ key: 'avg_duration', label: 'Visit Duration', format: (v) => formatDuration(v) },
]
const chartConfig = {
visitors: { label: 'Unique Visitors', color: '#FD5E0F' },
pageviews: { label: 'Total Pageviews', color: '#FD5E0F' },
bounce_rate: { label: 'Bounce Rate', color: '#FD5E0F' },
avg_duration: { label: 'Visit Duration', color: '#FD5E0F' },
} satisfies ChartConfig
// ─── Custom Tooltip ─────────────────────────────────────────────────
interface TooltipProps {
active?: boolean
payload?: Array<{ dataKey: string; value: number; color: string }>
label?: string
metric: MetricType
}
function CustomTooltip({ active, payload, metric }: TooltipProps) {
if (active && payload && payload.length) {
const entry = payload[0]
const config = METRIC_CONFIGS.find((m) => m.key === metric)
if (config) {
return (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 p-3 shadow-sm shadow-black/5 min-w-[120px]">
<div className="flex items-center gap-2 text-sm">
<div className="size-1.5 rounded-full" style={{ backgroundColor: entry.color }}></div>
<span className="text-neutral-500 dark:text-neutral-400">{config.label}:</span>
<span className="font-semibold text-neutral-900 dark:text-white">{config.format(entry.value)}</span>
</div>
</div>
)
}
}
return null
const CHART_COLORS: Record<MetricType, string> = {
visitors: '#FD5E0F',
pageviews: '#FD5E0F',
bounce_rate: '#FD5E0F',
avg_duration: '#FD5E0F',
}
// ─── Chart Component ─────────────────────────────────────────────────
@@ -227,6 +197,7 @@ export default function Chart({
return {
date: formattedDate,
dateObj: new Date(item.date),
originalDate: item.date,
pageviews: item.pageviews,
visitors: item.visitors,
@@ -450,103 +421,60 @@ export default function Chart({
</div>
) : (
<div className="w-full" onContextMenu={handleChartContextMenu}>
<ChartContainer
config={chartConfig}
className="h-96 w-full overflow-visible [&_.recharts-curve.recharts-tooltip-cursor]:stroke-[initial]"
<VisxAreaChart
data={chartData as Record<string, unknown>[]}
xDataKey="dateObj"
aspectRatio="2.5 / 1"
margin={{ top: 20, right: 20, bottom: 40, left: 50 }}
>
<ComposedChart
data={chartData}
margin={{ top: 20, right: 20, left: 5, bottom: 20 }}
style={{ overflow: 'visible' }}
>
<defs>
<linearGradient id="areaFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={chartConfig[metric]?.color} stopOpacity={0.15} />
<stop offset="100%" stopColor={chartConfig[metric]?.color} stopOpacity={0.01} />
</linearGradient>
<filter id="lineShadow" x="-100%" y="-100%" width="300%" height="300%">
<feDropShadow
dx="4"
dy="6"
stdDeviation="25"
floodColor={`${chartConfig[metric]?.color}60`}
/>
</filter>
<filter id="dotShadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="2" dy="2" stdDeviation="3" floodColor="rgba(0,0,0,0.5)" />
</filter>
</defs>
<CartesianGrid
horizontal={true}
vertical={false}
stroke="var(--chart-grid)"
strokeOpacity={0.7}
/>
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{ fontSize: 11, fill: 'var(--chart-axis)' }}
tickMargin={10}
minTickGap={32}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 11, fill: 'var(--chart-axis)' }}
tickMargin={10}
tickCount={6}
tickFormatter={(value) => {
const config = METRIC_CONFIGS.find((m) => m.key === metric)
return config ? config.format(value) : value.toString()
}}
/>
<ChartTooltip content={<CustomTooltip metric={metric} />} cursor={{ strokeDasharray: '3 3', stroke: '#9ca3af' }} />
{/* Annotation reference lines */}
{visibleAnnotationMarkers.map((marker) => {
const primaryCategory = marker.annotations[0].category
const color = ANNOTATION_COLORS[primaryCategory] || ANNOTATION_COLORS.other
<VisxGrid horizontal vertical={false} stroke="var(--chart-grid)" strokeDasharray="4,4" />
<VisxArea
dataKey={metric}
fill={CHART_COLORS[metric]}
fillOpacity={0.15}
stroke={CHART_COLORS[metric]}
strokeWidth={2}
gradientToOpacity={0}
/>
<VisxXAxis
numTicks={6}
formatLabel={interval === 'minute' || interval === 'hour'
? (d) => `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`
: (d) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
}
/>
<VisxYAxis
numTicks={6}
formatValue={(v) => {
const config = METRIC_CONFIGS.find((m) => m.key === metric)
return config ? config.format(v) : v.toString()
}}
/>
<VisxChartTooltip
content={({ point }) => {
const dateObj = point.dateObj instanceof Date ? point.dateObj : new Date(point.dateObj as string || Date.now())
const config = METRIC_CONFIGS.find((m) => m.key === metric)
const value = point[metric] as number
const title = interval === 'minute' || interval === 'hour'
? `${String(dateObj.getUTCHours()).padStart(2, '0')}:${String(dateObj.getUTCMinutes()).padStart(2, '0')}`
: dateObj.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })
return (
<ReferenceLine
key={`ann-${marker.x}`}
x={marker.x}
stroke={color}
strokeDasharray="4 4"
strokeWidth={1.5}
strokeOpacity={0.6}
/>
<div className="px-3 py-2.5">
<div className="mb-2 font-medium text-neutral-400 text-xs">{title}</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: CHART_COLORS[metric] }} />
<span className="text-neutral-400 text-sm">{config?.label || metric}</span>
</div>
<span className="font-medium text-white text-sm tabular-nums">
{config ? config.format(value) : value}
</span>
</div>
</div>
)
})}
<Area
type="bump"
dataKey={metric}
fill="url(#areaFill)"
stroke="none"
/>
<Line
type="bump"
dataKey={metric}
stroke={chartConfig[metric]?.color}
strokeWidth={2}
filter="url(#lineShadow)"
dot={false}
activeDot={{
r: 6,
fill: chartConfig[metric]?.color,
stroke: 'white',
strokeWidth: 2,
filter: 'url(#dotShadow)',
}}
/>
</ComposedChart>
</ChartContainer>
}}
/>
</VisxAreaChart>
</div>
)}
</CardContent>

View File

@@ -8,10 +8,10 @@ export default function ContentHeader({
onMobileMenuOpen: () => void
}) {
return (
<div className="shrink-0 flex items-center border-b border-neutral-200/60 dark:border-neutral-800/60 bg-white/90 dark:bg-neutral-900/90 backdrop-blur-xl px-4 py-3.5 md:hidden">
<div className="shrink-0 flex items-center border-b border-neutral-800/60 bg-neutral-900/90 backdrop-blur-xl px-4 py-3.5 md:hidden">
<button
onClick={onMobileMenuOpen}
className="p-2 -ml-2 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white"
className="p-2 -ml-2 text-neutral-400 hover:text-white"
aria-label="Open navigation"
>
<MenuIcon className="w-5 h-5" />

View File

@@ -99,17 +99,17 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
return (
<>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="bg-neutral-900/80 border border-white/[0.08] rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Files className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
<h3 className="text-lg font-semibold text-white">
Pages
</h3>
{showViewAll && (
<button
onClick={() => setIsModalOpen(true)}
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
aria-label="View all pages"
>
<FrameCornersIcon className="w-4 h-4" weight="bold" />
@@ -125,8 +125,8 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
aria-selected={activeTab === tab}
className={`relative px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded cursor-pointer ${
activeTab === tab
? 'text-neutral-900 dark:text-white'
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
? 'text-white'
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-300'
}`}
>
{getTabLabel(tab)}
@@ -145,7 +145,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
<div className="space-y-2 flex-1 min-h-[270px]">
{!collectPagePaths ? (
<div className="h-full flex flex-col items-center justify-center text-center px-4">
<p className="text-neutral-500 dark:text-neutral-400 text-sm">Page path tracking is disabled in site settings</p>
<p className="text-neutral-400 text-sm">Page path tracking is disabled in site settings</p>
</div>
) : hasData ? (
<>
@@ -156,13 +156,13 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
<div
key={page.path}
onClick={() => onFilter?.({ dimension: 'page', operator: 'is', values: [page.path] })}
className={`relative flex items-center justify-between h-9 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
className={`relative flex items-center justify-between h-9 group hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
>
<div
className="absolute inset-y-0.5 left-0.5 bg-brand-orange/15 dark:bg-brand-orange/40 rounded-md transition-all"
style={{ width: `${barWidth}%` }}
/>
<div className="relative flex-1 truncate text-neutral-900 dark:text-white flex items-center">
<div className="relative flex-1 truncate text-white flex items-center">
<span className="truncate">{page.path}</span>
<a
href={`https://${domain.replace(/^https?:\/\//, '')}${page.path}`}
@@ -178,7 +178,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{totalPageviews > 0 ? `${Math.round((page.pageviews / totalPageviews) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
<span className="text-sm font-semibold text-neutral-400">
{formatNumber(page.pageviews)}
</span>
</div>
@@ -191,13 +191,13 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<LayoutDashboardIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
<div className="rounded-full bg-neutral-800 p-4">
<LayoutDashboardIcon className="w-8 h-8 text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
<h4 className="font-semibold text-white">
No page data yet
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
<p className="text-sm text-neutral-400 max-w-xs">
Your most visited pages will appear here as traffic arrives.
</p>
<Link
@@ -224,7 +224,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
value={modalSearch}
onChange={(e) => setModalSearch(e.target.value)}
placeholder="Search pages..."
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-800 border border-neutral-700 rounded-lg text-white placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
/>
</div>
<div className="max-h-[80vh]">
@@ -246,16 +246,16 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
<div
key={page.path}
onClick={() => { if (canFilter) { onFilter({ dimension: 'page', operator: 'is', values: [page.path] }); setIsModalOpen(false) } }}
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
className={`flex items-center justify-between h-9 group hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
>
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center">
<div className="flex-1 truncate text-white flex items-center">
<span className="truncate">{page.path}</span>
</div>
<div className="flex items-center gap-2 ml-4">
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{modalTotal > 0 ? `${Math.round((page.pageviews / modalTotal) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
<span className="text-sm font-semibold text-neutral-400">
{formatNumber(page.pageviews)}
</span>
</div>

View File

@@ -11,7 +11,7 @@ const Sidebar = dynamic(() => import('./Sidebar'), {
// so page content never occupies the sidebar zone
loading: () => (
<div
className="hidden md:block shrink-0 border-r border-neutral-200/60 dark:border-neutral-800/60 bg-white/90 dark:bg-neutral-900/90 backdrop-blur-xl"
className="hidden md:block shrink-0 border-r border-neutral-800/60 bg-neutral-900/90 backdrop-blur-xl"
style={{ width: 64 }}
/>
),

View File

@@ -150,7 +150,7 @@ export default function DottedMap({ data, className, formatValue = formatNumber
{tooltip && (
<div
className="fixed z-50 px-2.5 py-1.5 text-xs font-medium text-white bg-neutral-900 dark:bg-neutral-800 border border-neutral-700 rounded-lg shadow-lg pointer-events-none -translate-x-1/2 -translate-y-full -mt-2"
className="fixed z-50 px-2.5 py-1.5 text-xs font-medium text-white bg-neutral-800 border border-neutral-700 rounded-lg shadow-lg pointer-events-none -translate-x-1/2 -translate-y-full -mt-2"
style={{ left: tooltip.x, top: tooltip.y }}
>
<span>{getCountryName(tooltip.country)}</span>

View File

@@ -36,14 +36,14 @@ export default function EventProperties({ siteId, eventName, dateRange, onClose
const maxCount = values.length > 0 ? values[0].count : 1
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<div className="bg-neutral-900/80 border border-white/[0.08] rounded-2xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
<h3 className="text-lg font-semibold text-white">
Properties: <span className="text-brand-orange">{eventName.replace(/_/g, ' ')}</span>
</h3>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors cursor-pointer"
className="text-neutral-400 hover:text-neutral-300 transition-colors cursor-pointer"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
@@ -54,11 +54,11 @@ export default function EventProperties({ siteId, eventName, dateRange, onClose
{loading ? (
<div className="animate-pulse space-y-3">
{[1, 2, 3].map(i => (
<div key={i} className="h-8 bg-neutral-100 dark:bg-neutral-800 rounded-lg" />
<div key={i} className="h-8 bg-neutral-800 rounded-lg" />
))}
</div>
) : keys.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-4 text-center">
<p className="text-sm text-neutral-400 py-4 text-center">
No properties recorded for this event yet.
</p>
) : (
@@ -71,7 +71,7 @@ export default function EventProperties({ siteId, eventName, dateRange, onClose
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors cursor-pointer ${
selectedKey === k.key
? 'bg-brand-orange text-white'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
: 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
}`}
>
{k.key}
@@ -84,14 +84,14 @@ export default function EventProperties({ siteId, eventName, dateRange, onClose
<div key={v.value} className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-neutral-900 dark:text-white truncate">
<span className="text-sm font-medium text-white truncate">
{v.value}
</span>
<span className="text-xs font-semibold text-brand-orange tabular-nums ml-2">
{formatNumber(v.count)}
</span>
</div>
<div className="w-full h-1.5 bg-neutral-100 dark:bg-neutral-800 rounded-full overflow-hidden">
<div className="w-full h-1.5 bg-neutral-800 rounded-full overflow-hidden">
<div
className="h-full bg-brand-orange/60 rounded-full transition-all"
style={{ width: `${(v.count / maxCount) * 100}%` }}

View File

@@ -20,11 +20,11 @@ export default function GoalStats({ goalCounts, onSelectEvent }: GoalStatsProps)
const emptySlots = Math.max(0, 6 - list.length)
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="bg-neutral-900/80 border border-white/[0.08] rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Target className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
<h3 className="text-lg font-semibold text-white">
Goals & Events
</h3>
</div>
@@ -36,10 +36,10 @@ export default function GoalStats({ goalCounts, onSelectEvent }: GoalStatsProps)
<div
key={row.event_name}
onClick={() => onSelectEvent?.(row.event_name)}
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onSelectEvent ? ' cursor-pointer' : ''}`}
className={`flex items-center justify-between h-9 group hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onSelectEvent ? ' cursor-pointer' : ''}`}
>
<div className="flex items-center flex-1 min-w-0">
<span className="text-sm font-medium text-neutral-900 dark:text-white truncate">
<span className="text-sm font-medium text-white truncate">
{row.display_name ?? row.event_name.replace(/_/g, ' ')}
</span>
</div>
@@ -47,7 +47,7 @@ export default function GoalStats({ goalCounts, onSelectEvent }: GoalStatsProps)
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{total > 0 ? `${Math.round((row.count / total) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 tabular-nums">
<span className="text-sm font-semibold text-neutral-400 tabular-nums">
{formatNumber(row.count)}
</span>
</div>
@@ -59,14 +59,14 @@ export default function GoalStats({ goalCounts, onSelectEvent }: GoalStatsProps)
</div>
) : (
<div className="flex-1 min-h-[270px] flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<BookOpenIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
<div className="rounded-full bg-neutral-800 p-4">
<BookOpenIcon className="w-8 h-8 text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
<h4 className="font-semibold text-white">
Need help tracking goals?
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md">
Add <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-xs font-mono">pulse.track(&apos;event_name&apos;)</code> where actions happen on your site, then see counts here. Check our guide for step-by-step instructions.
<p className="text-sm text-neutral-400 max-w-md">
Add <code className="px-1.5 py-0.5 rounded bg-neutral-700 text-xs font-mono">pulse.track(&apos;event_name&apos;)</code> where actions happen on your site, then see counts here. Check our guide for step-by-step instructions.
</p>
<Link
href="/installation"

View File

@@ -90,13 +90,13 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
case 'T1':
return <ShieldCheck className="w-5 h-5 text-purple-600 dark:text-purple-400" />
case 'A1':
return <Detective className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
return <Detective className="w-5 h-5 text-neutral-400" />
case 'A2':
return <Broadcast className="w-5 h-5 text-blue-500 dark:text-blue-400" />
case 'O1':
case 'EU':
case 'AP':
return <GlobeIcon className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
return <GlobeIcon className="w-5 h-5 text-neutral-400" />
}
const FlagComponent = (Flags as Record<string, React.ComponentType<{ className?: string }>>)[countryCode]
@@ -216,17 +216,17 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
return (
<>
<div ref={containerRef} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div ref={containerRef} className="bg-neutral-900/80 border border-white/[0.08] rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
<h3 className="text-lg font-semibold text-white">
Locations
</h3>
{showViewAll && (
<button
onClick={() => setIsModalOpen(true)}
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
aria-label="View all locations"
>
<FrameCornersIcon className="w-4 h-4" weight="bold" />
@@ -242,8 +242,8 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
aria-selected={activeTab === tab}
className={`relative px-2.5 py-1 text-xs font-medium transition-colors capitalize focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded cursor-pointer ${
activeTab === tab
? 'text-neutral-900 dark:text-white'
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
? 'text-white'
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-300'
}`}
>
{tab}
@@ -262,20 +262,20 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
<div className="space-y-2 flex-1 min-h-[270px]">
{isTabDisabled() ? (
<div className="h-full flex flex-col items-center justify-center text-center px-4">
<p className="text-neutral-500 dark:text-neutral-400 text-sm">{getDisabledMessage()}</p>
<p className="text-neutral-400 text-sm">{getDisabledMessage()}</p>
</div>
) : isVisualTab ? (
hasData ? (
inView ? <DottedMap data={filterUnknown(countries) as { country: string; pageviews: number }[]} /> : null
) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
<div className="rounded-full bg-neutral-800 p-4">
<GlobeIcon className="w-8 h-8 text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
<h4 className="font-semibold text-white">
No location data yet
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
<p className="text-sm text-neutral-400 max-w-xs">
Visitor locations will appear here based on anonymous geographic data.
</p>
<Link
@@ -300,13 +300,13 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
<div
key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`}
onClick={() => canFilter && onFilter({ dimension: dim, operator: 'is', values: [filterValue!] })}
className={`relative flex items-center justify-between h-9 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
className={`relative flex items-center justify-between h-9 group hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
>
<div
className="absolute inset-y-0.5 left-0.5 bg-brand-orange/15 dark:bg-brand-orange/40 rounded-md transition-all"
style={{ width: `${barWidth}%` }}
/>
<div className="relative flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
<div className="relative flex-1 truncate text-white flex items-center gap-3">
<span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>
<span className="truncate">
{activeTab === 'countries' ? getCountryName(item.country ?? '') :
@@ -318,7 +318,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{totalPageviews > 0 ? `${Math.round((item.pageviews / totalPageviews) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
<span className="text-sm font-semibold text-neutral-400">
{formatNumber(item.pageviews)}
</span>
</div>
@@ -331,13 +331,13 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
<div className="rounded-full bg-neutral-800 p-4">
<GlobeIcon className="w-8 h-8 text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
<h4 className="font-semibold text-white">
No location data yet
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
<p className="text-sm text-neutral-400 max-w-xs">
Visitor locations will appear here based on anonymous geographic data.
</p>
</div>
@@ -358,7 +358,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
value={modalSearch}
onChange={(e) => setModalSearch(e.target.value)}
placeholder="Search locations..."
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-800 border border-neutral-700 rounded-lg text-white placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
/>
</div>
<div className="max-h-[80vh]">
@@ -387,9 +387,9 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
<div
key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`}
onClick={() => { if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [filterValue!] }); setIsModalOpen(false) } }}
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
className={`flex items-center justify-between h-9 group hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
>
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
<div className="flex-1 truncate text-white flex items-center gap-3">
<span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>
<span className="truncate">
{activeTab === 'countries' ? getCountryName(item.country ?? '') :
@@ -401,7 +401,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{modalTotal > 0 ? `${Math.round((item.pageviews / modalTotal) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
<span className="text-sm font-semibold text-neutral-400">
{formatNumber(item.pageviews)}
</span>
</div>

View File

@@ -131,14 +131,14 @@ export default function PeakHours({ siteId, dateRange }: PeakHoursProps) {
}
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="bg-neutral-900/80 border border-white/[0.08] rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Peak Hours</h3>
<h3 className="text-lg font-semibold text-white">Peak Hours</h3>
</div>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-5">
<p className="text-sm text-neutral-400 mb-5">
When your visitors are most active
</p>
@@ -146,8 +146,8 @@ export default function PeakHours({ siteId, dateRange }: PeakHoursProps) {
<div className="flex-1 min-h-[270px] flex flex-col justify-center gap-1.5">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="flex items-center gap-1">
<div className="w-7 h-3 rounded bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
<div className="flex-1 h-5 rounded bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
<div className="w-7 h-3 rounded bg-neutral-800 animate-pulse" />
<div className="flex-1 h-5 rounded bg-neutral-800 animate-pulse" />
</div>
))}
</div>
@@ -174,7 +174,7 @@ export default function PeakHours({ siteId, dateRange }: PeakHoursProps) {
key={`${animKey}-${dayIdx}-${bucket}`}
className={[
'aspect-square w-full rounded-[4px] border cursor-default transition-transform duration-100',
'border-neutral-200 dark:border-neutral-800',
'border-neutral-800',
isActive ? 'animate-cell-highlight' : '',
isHoveredCell ? 'scale-110 z-10 relative' : '',
isBestCell && !isHoveredCell ? 'ring-1 ring-brand-orange/40' : '',
@@ -201,14 +201,14 @@ export default function PeakHours({ siteId, dateRange }: PeakHoursProps) {
{Object.entries(BUCKET_LABELS).map(([b, label]) => (
<span
key={b}
className="absolute text-[10px] text-neutral-400 dark:text-neutral-600 -translate-x-1/2"
className="absolute text-[10px] text-neutral-600 -translate-x-1/2"
style={{ left: `${(Number(b) / BUCKETS) * 100}%` }}
>
{label}
</span>
))}
<span
className="absolute text-[10px] text-neutral-400 dark:text-neutral-600 -translate-x-full"
className="absolute text-[10px] text-neutral-600 -translate-x-full"
style={{ left: '100%' }}
>
24:00
@@ -222,7 +222,7 @@ export default function PeakHours({ siteId, dateRange }: PeakHoursProps) {
{HIGHLIGHT_COLORS.map((color, i) => (
<div
key={i}
className="w-[10px] h-[10px] rounded-[2px] border border-neutral-200 dark:border-neutral-800"
className="w-[10px] h-[10px] rounded-[2px] border border-neutral-800"
style={{ backgroundColor: color }}
/>
))}
@@ -245,7 +245,7 @@ export default function PeakHours({ siteId, dateRange }: PeakHoursProps) {
transform: 'translate(-50%, -100%)',
}}
>
<div className="bg-neutral-900 dark:bg-neutral-800 border border-neutral-700 text-white text-xs px-3 py-2 rounded-lg shadow-xl whitespace-nowrap">
<div className="bg-neutral-800 border border-neutral-700 text-white text-xs px-3 py-2 rounded-lg shadow-xl whitespace-nowrap">
<div className="font-semibold mb-1">
{DAYS[hovered.day]} {formatBucket(hovered.bucket)}
</div>
@@ -269,7 +269,7 @@ export default function PeakHours({ siteId, dateRange }: PeakHoursProps) {
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.6 }}
className="mt-4 text-xs text-neutral-500 dark:text-neutral-400 text-center"
className="mt-4 text-xs text-neutral-400 text-center"
>
Your busiest time is{' '}
<span className="text-brand-orange font-medium">
@@ -279,9 +279,15 @@ export default function PeakHours({ siteId, dateRange }: PeakHoursProps) {
)}
</>
) : (
<div className="flex-1 min-h-[270px] flex flex-col items-center justify-center text-center gap-3">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
No data available for this period
<div className="flex-1 min-h-[270px] flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-800 p-4">
<Clock className="w-8 h-8 text-neutral-400" />
</div>
<h4 className="font-semibold text-white">
No peak hours yet
</h4>
<p className="text-sm text-neutral-400 max-w-xs">
Once your site receives traffic, this heatmap will show when your visitors are most active.
</p>
</div>
)}

View File

@@ -9,15 +9,15 @@ interface RealtimeVisitorsProps {
export default function RealtimeVisitors({ count }: RealtimeVisitorsProps) {
return (
<div
className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6"
className="bg-neutral-900/80 border border-white/[0.08] rounded-2xl p-6"
>
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-neutral-600 dark:text-neutral-400">
<div className="text-sm text-neutral-400">
Real-time Visitors
</div>
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
</div>
<div className="text-3xl font-bold text-neutral-900 dark:text-white">
<div className="text-3xl font-bold text-white">
<AnimatedNumber value={count} format={(v) => v.toLocaleString()} />
</div>
</div>

View File

@@ -28,13 +28,13 @@ export default function ScrollDepth({ goalCounts, totalPageviews }: ScrollDepthP
}))
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="bg-neutral-900/80 border border-white/[0.08] rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
<h3 className="text-lg font-semibold text-white">
Scroll Depth
</h3>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
<p className="text-sm text-neutral-400 mb-4">
% of visitors who scrolled this far
</p>
@@ -73,13 +73,13 @@ export default function ScrollDepth({ goalCounts, totalPageviews }: ScrollDepthP
</div>
) : (
<div className="flex-1 min-h-[270px] flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<BarChartIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
<div className="rounded-full bg-neutral-800 p-4">
<BarChartIcon className="w-8 h-8 text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
<h4 className="font-semibold text-white">
No scroll data yet
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md">
<p className="text-sm text-neutral-400 max-w-md">
Scroll depth tracking is automatic data will appear here once visitors start scrolling on your pages.
</p>
</div>

View File

@@ -1,14 +1,26 @@
'use client'
import Link from 'next/link'
import { MagnifyingGlass, CaretUp, CaretDown } from '@phosphor-icons/react'
import { useGSCStatus, useGSCOverview, useGSCTopQueries } from '@/lib/swr/dashboard'
import { useState, useEffect } from 'react'
import { motion } from 'framer-motion'
import { logger } from '@/lib/utils/logger'
import { formatNumber, Modal } from '@ciphera-net/ui'
import { MagnifyingGlass, CaretUp, CaretDown, FrameCornersIcon } from '@phosphor-icons/react'
import { useGSCStatus, useGSCOverview, useGSCTopQueries, useGSCTopPages } from '@/lib/swr/dashboard'
import { getGSCTopQueries, getGSCTopPages } from '@/lib/api/gsc'
import type { GSCDataRow } from '@/lib/api/gsc'
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
import { ListSkeleton } from '@/components/skeletons'
import VirtualList from './VirtualList'
interface SearchPerformanceProps {
siteId: string
dateRange: { start: string; end: string }
}
type Tab = 'queries' | 'pages'
const LIMIT = 7
function ChangeArrow({ current, previous, invert = false }: { current: number; previous: number; invert?: boolean }) {
if (!previous || previous === 0) return null
const improved = invert ? current < previous : current > previous
@@ -21,109 +33,263 @@ function ChangeArrow({ current, previous, invert = false }: { current: number; p
)
}
function getPositionBadgeClasses(position: number): string {
if (position <= 10) return 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 dark:bg-emerald-500/20'
if (position <= 20) return 'text-brand-orange dark:text-brand-orange bg-brand-orange/10 dark:bg-brand-orange/20'
if (position <= 50) return 'text-neutral-400 dark:text-neutral-500 bg-neutral-800'
return 'text-red-500 dark:text-red-400 bg-red-500/10 dark:bg-red-500/20'
}
export default function SearchPerformance({ siteId, dateRange }: SearchPerformanceProps) {
const [activeTab, setActiveTab] = useState<Tab>('queries')
const handleTabKeyDown = useTabListKeyboard()
const [isModalOpen, setIsModalOpen] = useState(false)
const [modalSearch, setModalSearch] = useState('')
const [fullData, setFullData] = useState<GSCDataRow[]>([])
const [isLoadingFull, setIsLoadingFull] = useState(false)
const { data: gscStatus } = useGSCStatus(siteId)
const { data: overview, isLoading: overviewLoading } = useGSCOverview(siteId, dateRange.start, dateRange.end)
const { data: queriesData, isLoading: queriesLoading } = useGSCTopQueries(siteId, dateRange.start, dateRange.end, 5, 0)
const { data: queriesData, isLoading: queriesLoading } = useGSCTopQueries(siteId, dateRange.start, dateRange.end, LIMIT, 0)
const { data: pagesData, isLoading: pagesLoading } = useGSCTopPages(siteId, dateRange.start, dateRange.end, LIMIT, 0)
// Don't render if GSC is not connected or no data
// Fetch full data when modal opens (matches ContentStats/TopReferrers pattern)
useEffect(() => {
if (isModalOpen) {
const fetchData = async () => {
setIsLoadingFull(true)
try {
if (activeTab === 'queries') {
const data = await getGSCTopQueries(siteId, dateRange.start, dateRange.end, 100, 0)
setFullData(data.queries ?? [])
} else {
const data = await getGSCTopPages(siteId, dateRange.start, dateRange.end, 100, 0)
setFullData(data.pages ?? [])
}
} catch (e) {
logger.error(e)
} finally {
setIsLoadingFull(false)
}
}
fetchData()
} else {
setFullData([])
}
}, [isModalOpen, activeTab, siteId, dateRange])
// Don't render if GSC is not connected
if (!gscStatus?.connected) return null
const isLoading = overviewLoading || queriesLoading
const isLoading = overviewLoading || queriesLoading || pagesLoading
const queries = queriesData?.queries ?? []
const pages = pagesData?.pages ?? []
const hasData = overview && (overview.total_clicks > 0 || overview.total_impressions > 0)
// Hide panel entirely if loaded but no data
if (!isLoading && !hasData) return null
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<MagnifyingGlass className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Search
</h3>
</div>
<Link
href={`/sites/${siteId}/search`}
className="text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
>
View all &rarr;
</Link>
</div>
const data = activeTab === 'queries' ? queries : pages
const totalImpressions = data.reduce((sum, d) => sum + d.impressions, 0)
const displayedData = data.slice(0, LIMIT)
const emptySlots = Math.max(0, LIMIT - displayedData.length)
const showViewAll = data.length >= LIMIT
{isLoading ? (
/* Loading skeleton */
<div className="flex-1 space-y-4">
<div className="flex items-center gap-6">
<div className="h-4 w-20 bg-neutral-100 dark:bg-neutral-800 rounded animate-pulse" />
<div className="h-4 w-24 bg-neutral-100 dark:bg-neutral-800 rounded animate-pulse" />
<div className="h-4 w-20 bg-neutral-100 dark:bg-neutral-800 rounded animate-pulse" />
const getLabel = (row: GSCDataRow) => activeTab === 'queries' ? row.query : row.page
const getTabLabel = (tab: Tab) => tab === 'queries' ? 'Queries' : 'Pages'
return (
<>
<div className="bg-neutral-900/80 border border-white/[0.08] rounded-2xl p-6 h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<MagnifyingGlass className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
<h3 className="text-lg font-semibold text-white">Search</h3>
{showViewAll && (
<button
onClick={() => setIsModalOpen(true)}
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
aria-label="View all search data"
>
<FrameCornersIcon className="w-4 h-4" weight="bold" />
</button>
)}
</div>
<div className="space-y-2 mt-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-9 bg-neutral-100 dark:bg-neutral-800 rounded-lg animate-pulse" />
<div className="flex gap-1 overflow-x-auto scrollbar-hide" role="tablist" aria-label="Search data tabs" onKeyDown={handleTabKeyDown}>
{(['queries', 'pages'] as Tab[]).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
role="tab"
aria-selected={activeTab === tab}
className={`relative px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded cursor-pointer ${
activeTab === tab
? 'text-white'
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-300'
}`}
>
{getTabLabel(tab)}
{activeTab === tab && (
<motion.div
layoutId="searchTab"
className="absolute inset-x-0 -bottom-px h-0.5 bg-brand-orange"
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
/>
)}
</button>
))}
</div>
</div>
) : (
<>
{/* Inline stats row */}
<div className="flex items-center gap-5 mb-4">
<div className="flex items-center gap-1.5">
<span className="text-xs text-neutral-500 dark:text-neutral-400">Clicks</span>
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
{(overview?.total_clicks ?? 0).toLocaleString()}
</span>
<ChangeArrow current={overview?.total_clicks ?? 0} previous={overview?.prev_clicks ?? 0} />
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-neutral-500 dark:text-neutral-400">Impressions</span>
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
{(overview?.total_impressions ?? 0).toLocaleString()}
</span>
<ChangeArrow current={overview?.total_impressions ?? 0} previous={overview?.prev_impressions ?? 0} />
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-neutral-500 dark:text-neutral-400">Avg Position</span>
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
{(overview?.avg_position ?? 0).toFixed(1)}
</span>
<ChangeArrow current={overview?.avg_position ?? 0} previous={overview?.prev_avg_position ?? 0} invert />
</div>
</div>
{/* Top 5 queries list */}
<div className="space-y-1 flex-1">
{queries.length > 0 ? (
queries.map((q) => (
<div
key={q.query}
className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors"
>
<span className="text-sm text-neutral-900 dark:text-white truncate flex-1 min-w-0" title={q.query}>
{q.query}
</span>
<div className="flex items-center gap-3 ml-4 shrink-0">
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
{q.clicks.toLocaleString()}
</span>
<span className="text-xs text-neutral-400 dark:text-neutral-500 bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded font-medium">
{q.position.toFixed(1)}
</span>
</div>
</div>
))
) : (
<div className="flex-1 flex items-center justify-center py-6">
<p className="text-sm text-neutral-400 dark:text-neutral-500">No search data yet</p>
</div>
)}
{isLoading ? (
<div className="flex-1 space-y-4">
<div className="flex items-center gap-6">
<div className="h-4 w-20 bg-neutral-800 rounded animate-pulse" />
<div className="h-4 w-24 bg-neutral-800 rounded animate-pulse" />
<div className="h-4 w-20 bg-neutral-800 rounded animate-pulse" />
</div>
<div className="space-y-2 mt-4">
<ListSkeleton rows={LIMIT} />
</div>
</div>
</>
)}
</div>
) : (
<>
{/* Inline stats row */}
<div className="flex items-center gap-5 mb-4">
<div className="flex items-center gap-1.5">
<span className="text-xs text-neutral-400">Clicks</span>
<span className="text-sm font-semibold text-white">
{formatNumber(overview?.total_clicks ?? 0)}
</span>
<ChangeArrow current={overview?.total_clicks ?? 0} previous={overview?.prev_clicks ?? 0} />
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-neutral-400">Impressions</span>
<span className="text-sm font-semibold text-white">
{formatNumber(overview?.total_impressions ?? 0)}
</span>
<ChangeArrow current={overview?.total_impressions ?? 0} previous={overview?.prev_impressions ?? 0} />
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-neutral-400">Avg Position</span>
<span className="text-sm font-semibold text-white">
{(overview?.avg_position ?? 0).toFixed(1)}
</span>
<ChangeArrow current={overview?.avg_position ?? 0} previous={overview?.prev_avg_position ?? 0} invert />
</div>
</div>
{/* Data list */}
<div className="space-y-2 flex-1 min-h-[270px]">
{displayedData.length > 0 ? (
<>
{displayedData.map((row) => {
const maxImpressions = displayedData[0]?.impressions ?? 0
const barWidth = maxImpressions > 0 ? (row.impressions / maxImpressions) * 75 : 0
const label = getLabel(row)
return (
<div
key={label}
className="relative flex items-center justify-between h-9 group hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors"
>
<div
className="absolute inset-y-0.5 left-0.5 bg-brand-orange/15 dark:bg-brand-orange/40 rounded-md transition-all"
style={{ width: `${barWidth}%` }}
/>
<span className="relative text-sm text-white truncate flex-1 min-w-0" title={label}>
{label}
</span>
<div className="relative flex items-center gap-3 ml-4 shrink-0">
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{totalImpressions > 0 ? `${Math.round((row.impressions / totalImpressions) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-400">
{formatNumber(row.clicks)}
</span>
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${getPositionBadgeClasses(row.position)}`}>
{row.position.toFixed(1)}
</span>
</div>
</div>
)
})}
{Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))}
</>
) : (
<div className="flex-1 flex items-center justify-center py-6">
<p className="text-sm text-neutral-400 dark:text-neutral-500">No search data yet</p>
</div>
)}
</div>
</>
)}
</div>
{/* Expand modal */}
<Modal
isOpen={isModalOpen}
onClose={() => { setIsModalOpen(false); setModalSearch('') }}
title={`Search ${getTabLabel(activeTab)}`}
className="max-w-2xl"
>
<div>
<input
type="text"
value={modalSearch}
onChange={(e) => setModalSearch(e.target.value)}
placeholder={`Search ${activeTab}...`}
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-800 border border-neutral-700 rounded-lg text-white placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
/>
</div>
<div className="max-h-[80vh]">
{isLoadingFull ? (
<div className="py-4">
<ListSkeleton rows={10} />
</div>
) : (() => {
const source = fullData.length > 0 ? fullData : data
const modalData = source.filter(row => {
if (!modalSearch) return true
return getLabel(row).toLowerCase().includes(modalSearch.toLowerCase())
})
const modalTotal = modalData.reduce((sum, r) => sum + r.impressions, 0)
return (
<VirtualList
items={modalData}
estimateSize={36}
className="max-h-[80vh] overflow-y-auto pr-2"
renderItem={(row) => {
const label = getLabel(row)
return (
<div
key={label}
className="flex items-center justify-between h-9 group hover:bg-neutral-800 rounded-lg px-2 transition-colors"
>
<span className="flex-1 truncate text-sm text-white" title={label}>
{label}
</span>
<div className="flex items-center gap-3 ml-4">
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{modalTotal > 0 ? `${Math.round((row.impressions / modalTotal) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-400">
{formatNumber(row.clicks)}
</span>
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${getPositionBadgeClasses(row.position)}`}>
{row.position.toFixed(1)}
</span>
</div>
</div>
)
}}
/>
)
})()}
</div>
</Modal>
</>
)
}

View File

@@ -162,12 +162,12 @@ function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollaps
setOpen(!open)
}
}}
className="w-full flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 overflow-hidden"
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-neutral-800 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-100 dark:bg-neutral-800" />}
{!faviconLoaded && <span className="w-5 h-5 rounded animate-pulse bg-neutral-800" />}
<img
src={faviconUrl}
alt=""
@@ -187,14 +187,14 @@ function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollaps
</button>
{open && (
<div className="absolute left-3 top-full mt-1 z-50 w-[240px] bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden">
<div className="absolute left-3 top-full mt-1 z-50 w-[240px] bg-neutral-900 border border-neutral-700 rounded-xl shadow-xl overflow-hidden">
<div className="p-2">
<input
type="text"
placeholder="Search sites..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full px-3 py-1.5 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg outline-none focus:ring-2 focus:ring-brand-orange/40 text-neutral-900 dark:text-white placeholder:text-neutral-400"
className="w-full px-3 py-1.5 text-sm bg-neutral-800 border border-neutral-700 rounded-lg outline-none focus:ring-2 focus:ring-brand-orange/40 text-white placeholder:text-neutral-400"
autoFocus
/>
</div>
@@ -206,7 +206,7 @@ function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollaps
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-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800'
: 'text-neutral-300 hover:bg-neutral-800'
}`}
>
<img
@@ -222,8 +222,8 @@ function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollaps
))}
{filtered.length === 0 && <p className="px-4 py-3 text-sm text-neutral-400">No sites found</p>}
</div>
<div className="border-t border-neutral-200 dark:border-neutral-700 p-2">
<Link href="/sites/new" onClick={() => setOpen(false)} className="flex items-center gap-2 px-3 py-1.5 text-sm text-brand-orange hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg">
<div className="border-t border-neutral-700 p-2">
<Link href="/sites/new" onClick={() => setOpen(false)} className="flex items-center gap-2 px-3 py-1.5 text-sm text-brand-orange hover:bg-neutral-800 rounded-lg">
<PlusIcon className="w-4 h-4" />
Add new site
</Link>
@@ -256,7 +256,7 @@ function NavLink({
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden ${
active
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800'
: 'text-neutral-400 hover:text-white hover:bg-neutral-800'
}`}
>
<span className="w-7 h-7 flex items-center justify-center shrink-0">
@@ -357,7 +357,7 @@ export default function Sidebar({
<span className="w-9 h-9 flex items-center justify-center shrink-0">
<img src="/pulse_icon_no_margins.png" alt="Pulse" className="w-9 h-9 shrink-0 object-contain group-hover:scale-105 transition-transform duration-200" />
</span>
<span className={`text-xl font-bold text-neutral-900 dark:text-white tracking-tight group-hover:text-brand-orange whitespace-nowrap transition-opacity duration-150 ${c ? 'opacity-0' : 'opacity-100'}`}>
<span className={`text-xl font-bold text-white tracking-tight group-hover:text-brand-orange whitespace-nowrap transition-opacity duration-150 ${c ? 'opacity-0' : 'opacity-100'}`}>
Pulse
</span>
</Link>
@@ -387,7 +387,7 @@ export default function Sidebar({
</nav>
{/* Bottom — utility items */}
<div className="border-t border-neutral-200/60 dark:border-neutral-800/60 px-2 py-3 shrink-0">
<div className="border-t border-neutral-800/60 px-2 py-3 shrink-0">
{/* Notifications, Profile — same layout as nav items */}
<div className="space-y-0.5 mb-1">
<span title={c ? 'Notifications' : undefined}>
@@ -418,7 +418,7 @@ export default function Sidebar({
{!isMobile && (
<button
onClick={toggle}
className="flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 w-full overflow-hidden"
className="flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-400 dark:text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 w-full overflow-hidden"
title={collapsed ? 'Expand sidebar (press [)' : 'Collapse sidebar (press [)'}
>
<span className="w-7 h-7 flex items-center justify-center shrink-0">
@@ -437,7 +437,7 @@ export default function Sidebar({
<>
{/* Desktop — ssr:false means this only renders on client, no hydration flash */}
<aside
className="hidden md:flex flex-col shrink-0 border-r border-neutral-200/60 dark:border-neutral-800/60 bg-white/90 dark:bg-neutral-900/90 backdrop-blur-xl overflow-hidden relative z-10"
className="hidden md:flex flex-col shrink-0 border-r border-neutral-800/60 bg-neutral-900/90 backdrop-blur-xl overflow-hidden relative z-10"
style={{ width: collapsed ? COLLAPSED : EXPANDED, transition: 'width 200ms cubic-bezier(0.4, 0, 0.2, 1)' }}
>
{sidebarContent(false)}
@@ -447,10 +447,10 @@ export default function Sidebar({
{mobileOpen && (
<>
<div className="fixed inset-0 z-40 bg-black/30 md:hidden" onClick={onMobileClose} />
<aside className="fixed inset-y-0 left-0 z-50 w-72 bg-white dark:bg-neutral-900 border-r border-neutral-200 dark:border-neutral-800 shadow-xl md:hidden animate-in slide-in-from-left duration-200">
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-800">
<span className="text-sm font-semibold text-neutral-900 dark:text-white">Navigation</span>
<button onClick={onMobileClose} className="p-1.5 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300">
<aside className="fixed inset-y-0 left-0 z-50 w-72 bg-neutral-900 border-r border-neutral-800 shadow-xl md:hidden animate-in slide-in-from-left duration-200">
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-800">
<span className="text-sm font-semibold text-white">Navigation</span>
<button onClick={onMobileClose} className="p-1.5 text-neutral-400 hover:text-neutral-300">
<XIcon className="w-5 h-5" />
</button>
</div>

View File

@@ -131,17 +131,17 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
return (
<>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="bg-neutral-900/80 border border-white/[0.08] rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<DeviceMobile className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
<h3 className="text-lg font-semibold text-white">
Technology
</h3>
{showViewAll && (
<button
onClick={() => setIsModalOpen(true)}
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
aria-label="View all technology"
>
<FrameCornersIcon className="w-4 h-4" weight="bold" />
@@ -157,8 +157,8 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
aria-selected={activeTab === tab}
className={`relative px-2.5 py-1 text-xs font-medium transition-colors capitalize focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded cursor-pointer ${
activeTab === tab
? 'text-neutral-900 dark:text-white'
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
? 'text-white'
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-300'
}`}
>
{tab}
@@ -177,7 +177,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
<div className="space-y-2 flex-1 min-h-[270px]">
{isTabDisabled() ? (
<div className="h-full flex flex-col items-center justify-center text-center px-4">
<p className="text-neutral-500 dark:text-neutral-400 text-sm">{getDisabledMessage()}</p>
<p className="text-neutral-400 text-sm">{getDisabledMessage()}</p>
</div>
) : hasData ? (
<>
@@ -190,13 +190,13 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
<div
key={item.name}
onClick={() => canFilter && onFilter({ dimension: dim, operator: 'is', values: [item.name] })}
className={`relative flex items-center justify-between h-9 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
className={`relative flex items-center justify-between h-9 group hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
>
<div
className="absolute inset-y-0.5 left-0.5 bg-brand-orange/15 dark:bg-brand-orange/40 rounded-md transition-all"
style={{ width: `${barWidth}%` }}
/>
<div className="relative flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
<div className="relative flex-1 truncate text-white flex items-center gap-3">
{item.icon && <span className="text-lg">{item.icon}</span>}
<span className="truncate">{capitalize(item.name)}</span>
</div>
@@ -204,7 +204,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{totalPageviews > 0 ? `${Math.round((item.pageviews / totalPageviews) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
<span className="text-sm font-semibold text-neutral-400">
{formatNumber(item.pageviews)}
</span>
</div>
@@ -217,13 +217,13 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<GridIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
<div className="rounded-full bg-neutral-800 p-4">
<GridIcon className="w-8 h-8 text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
<h4 className="font-semibold text-white">
No technology data yet
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
<p className="text-sm text-neutral-400 max-w-xs">
Browser, OS, and device information will appear as visitors arrive.
</p>
<Link
@@ -250,7 +250,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
value={modalSearch}
onChange={(e) => setModalSearch(e.target.value)}
placeholder="Search technology..."
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-800 border border-neutral-700 rounded-lg text-white placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
/>
</div>
<div className="max-h-[80vh]">
@@ -273,9 +273,9 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
<div
key={item.name}
onClick={() => { if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [item.name] }); setIsModalOpen(false) } }}
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
className={`flex items-center justify-between h-9 group hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
>
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
<div className="flex-1 truncate text-white flex items-center gap-3">
{item.icon && <span className="text-lg">{item.icon}</span>}
<span className="truncate">{capitalize(item.name)}</span>
</div>
@@ -283,7 +283,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{modalTotal > 0 ? `${Math.round((item.pageviews / modalTotal) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
<span className="text-sm font-semibold text-neutral-400">
{formatNumber(item.pageviews)}
</span>
</div>

View File

@@ -0,0 +1,47 @@
'use client'
import { motion } from 'framer-motion'
import { ArrowRight } from '@phosphor-icons/react'
import { Button } from '@ciphera-net/ui'
import { initiateOAuthFlow } from '@/lib/api/oauth'
import Link from 'next/link'
export default function CTASection() {
return (
<section className="py-20 lg:py-32">
<div className="container mx-auto px-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="relative overflow-hidden rounded-xl border border-white/[0.06] bg-neutral-900/80 px-6 py-20 sm:px-10 sm:py-24 max-w-6xl mx-auto"
>
{/* Atmosphere inside card */}
<div className="absolute inset-0 -z-10 pointer-events-none">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-brand-orange/5 rounded-full blur-[150px]" />
</div>
<div className="relative z-10 text-center max-w-2xl mx-auto">
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
Start tracking with privacy.
</h2>
<p className="text-lg text-neutral-300 mb-10">
Join thousands of developers who respect their users&apos; privacy while getting the insights they need.
</p>
<div className="flex flex-row gap-3 justify-center flex-wrap">
<Button onClick={() => initiateOAuthFlow()} variant="primary" className="px-6 py-3 shadow-lg shadow-brand-orange/20 gap-2">
Try Pulse Free <ArrowRight weight="bold" className="w-4 h-4" />
</Button>
<Link href="/pricing">
<Button variant="secondary" className="px-6 py-3">
View Pricing
</Button>
</Link>
</div>
</div>
</motion.div>
</div>
</section>
)
}

View File

@@ -0,0 +1,111 @@
'use client'
import { motion } from 'framer-motion'
import Image from 'next/image'
import { Check, X } from '@phosphor-icons/react'
const pulseFeatures = [
{ label: 'No cookies required', has: true },
{ label: 'GDPR compliant by default', has: true },
{ label: 'No consent banner needed', has: true },
{ label: 'Open source client', has: true },
{ label: 'Script under 2KB', has: true },
{ label: 'Swiss infrastructure', has: true },
{ label: 'No cross-site tracking', has: true },
{ label: 'Free tier available', has: true },
{ label: 'Real-time dashboard', has: true },
]
const gaFeatures = [
{ label: 'Requires cookies', has: false },
{ label: 'GDPR requires configuration', has: false },
{ label: 'Consent banner required', has: false },
{ label: 'Closed source', has: false },
{ label: 'Script over 45KB', has: false },
{ label: 'US infrastructure', has: false },
{ label: 'Cross-site tracking', has: false },
{ label: 'Free tier available', has: true },
{ label: 'Real-time dashboard', has: true },
]
export default function ComparisonCards() {
return (
<section className="py-20 lg:py-32 border-t border-white/[0.04]">
<div className="container mx-auto px-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-center mb-16"
>
<h2 className="text-3xl sm:text-4xl md:text-5xl font-bold text-white leading-tight mb-4">
How Pulse compares.
</h2>
<p className="text-lg text-neutral-400 max-w-2xl mx-auto">
Privacy-first analytics doesn&apos;t mean less insight. See how Pulse stacks up.
</p>
</motion.div>
<div className="grid md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{/* Pulse — highlighted */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1 }}
className="rounded-xl border border-brand-orange/20 bg-neutral-900/80 p-8 relative overflow-hidden"
>
<div className="absolute top-0 left-0 right-0 h-[3px] bg-brand-orange" />
<div className="flex items-center gap-3 mb-6">
<Image src="/pulse_icon_no_margins.png" alt="Pulse" width={40} height={40} className="rounded-lg" unoptimized />
<div>
<h3 className="text-xl font-bold text-white">Pulse</h3>
<p className="text-xs text-brand-orange">Privacy-first analytics</p>
</div>
</div>
<ul className="space-y-4">
{pulseFeatures.map((f) => (
<li key={f.label} className="flex items-center gap-3">
<Check weight="bold" className="w-5 h-5 text-brand-orange shrink-0" />
<span className="text-neutral-300 text-sm">{f.label}</span>
</li>
))}
</ul>
</motion.div>
{/* Google Analytics — muted */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
className="rounded-xl border border-white/[0.08] bg-neutral-900/80 p-8"
>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-lg bg-neutral-800 flex items-center justify-center text-lg">
📊
</div>
<div>
<h3 className="text-xl font-bold text-white">Google Analytics</h3>
<p className="text-xs text-neutral-500">Traditional tracking</p>
</div>
</div>
<ul className="space-y-4">
{gaFeatures.map((f) => (
<li key={f.label} className="flex items-center gap-3">
{f.has ? (
<Check weight="bold" className="w-5 h-5 text-green-500 shrink-0" />
) : (
<X weight="bold" className="w-5 h-5 text-red-500 shrink-0" />
)}
<span className="text-neutral-400 text-sm">{f.label}</span>
</li>
))}
</ul>
</motion.div>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,273 @@
'use client'
import Chart from '@/components/dashboard/Chart'
import ContentStats from '@/components/dashboard/ContentStats'
import TopReferrers from '@/components/dashboard/TopReferrers'
import Locations from '@/components/dashboard/Locations'
import TechSpecs from '@/components/dashboard/TechSpecs'
import { useState } from 'react'
// ─── Fake Data ───────────────────────────────────────────────────────
const FAKE_STATS = { pageviews: 8432, visitors: 2847, bounce_rate: 42, avg_duration: 154 }
const FAKE_PREV_STATS = { pageviews: 7821, visitors: 2543, bounce_rate: 45, avg_duration: 134 }
const FAKE_DAILY_STATS = [
{ date: '2026-03-21 00:00:00', pageviews: 42, visitors: 26, bounce_rate: 46, avg_duration: 118 },
{ date: '2026-03-21 01:00:00', pageviews: 38, visitors: 24, bounce_rate: 47, avg_duration: 115 },
{ date: '2026-03-21 02:00:00', pageviews: 35, visitors: 22, bounce_rate: 47, avg_duration: 112 },
{ date: '2026-03-21 03:00:00', pageviews: 34, visitors: 21, bounce_rate: 48, avg_duration: 110 },
{ date: '2026-03-21 04:00:00', pageviews: 36, visitors: 23, bounce_rate: 47, avg_duration: 112 },
{ date: '2026-03-21 05:00:00', pageviews: 45, visitors: 29, bounce_rate: 46, avg_duration: 116 },
{ date: '2026-03-21 06:00:00', pageviews: 62, visitors: 40, bounce_rate: 45, avg_duration: 122 },
{ date: '2026-03-21 07:00:00', pageviews: 95, visitors: 62, bounce_rate: 43, avg_duration: 132 },
{ date: '2026-03-21 08:00:00', pageviews: 148, visitors: 98, bounce_rate: 41, avg_duration: 145 },
{ date: '2026-03-21 09:00:00', pageviews: 215, visitors: 145, bounce_rate: 39, avg_duration: 155 },
{ date: '2026-03-21 10:00:00', pageviews: 285, visitors: 192, bounce_rate: 38, avg_duration: 162 },
{ date: '2026-03-21 11:00:00', pageviews: 338, visitors: 228, bounce_rate: 37, avg_duration: 168 },
{ date: '2026-03-21 12:00:00', pageviews: 355, visitors: 240, bounce_rate: 38, avg_duration: 165 },
{ date: '2026-03-21 13:00:00', pageviews: 372, visitors: 252, bounce_rate: 37, avg_duration: 170 },
{ date: '2026-03-21 14:00:00', pageviews: 390, visitors: 265, bounce_rate: 36, avg_duration: 175 },
{ date: '2026-03-21 15:00:00', pageviews: 385, visitors: 260, bounce_rate: 36, avg_duration: 173 },
{ date: '2026-03-21 16:00:00', pageviews: 362, visitors: 245, bounce_rate: 37, avg_duration: 168 },
{ date: '2026-03-21 17:00:00', pageviews: 325, visitors: 218, bounce_rate: 38, avg_duration: 162 },
{ date: '2026-03-21 18:00:00', pageviews: 282, visitors: 190, bounce_rate: 40, avg_duration: 155 },
{ date: '2026-03-21 19:00:00', pageviews: 238, visitors: 160, bounce_rate: 41, avg_duration: 148 },
{ date: '2026-03-21 20:00:00', pageviews: 195, visitors: 132, bounce_rate: 42, avg_duration: 140 },
{ date: '2026-03-21 21:00:00', pageviews: 155, visitors: 105, bounce_rate: 43, avg_duration: 132 },
{ date: '2026-03-21 22:00:00', pageviews: 112, visitors: 75, bounce_rate: 44, avg_duration: 125 },
{ date: '2026-03-21 23:00:00', pageviews: 72, visitors: 46, bounce_rate: 45, avg_duration: 120 },
]
const FAKE_TOP_PAGES = [
{ path: '/', pageviews: 2341, visits: 1892 },
{ path: '/products/pulse', pageviews: 1567, visits: 1234 },
{ path: '/products/drop', pageviews: 987, visits: 812 },
{ path: '/pricing', pageviews: 876, visits: 723 },
{ path: '/blog/privacy-first-analytics', pageviews: 654, visits: 543 },
{ path: '/about', pageviews: 432, visits: 367 },
{ path: '/docs/getting-started', pageviews: 389, visits: 312 },
{ path: '/blog/end-to-end-encryption', pageviews: 345, visits: 289 },
{ path: '/contact', pageviews: 287, visits: 234 },
{ path: '/careers', pageviews: 198, visits: 167 },
]
const FAKE_ENTRY_PAGES = [
{ path: '/', pageviews: 1987, visits: 1654 },
{ path: '/products/pulse', pageviews: 1123, visits: 987 },
{ path: '/blog/privacy-first-analytics', pageviews: 567, visits: 489 },
{ path: '/products/drop', pageviews: 534, visits: 456 },
{ path: '/pricing', pageviews: 423, visits: 378 },
{ path: '/docs/getting-started', pageviews: 312, visits: 267 },
{ path: '/about', pageviews: 234, visits: 198 },
{ path: '/blog/end-to-end-encryption', pageviews: 198, visits: 167 },
{ path: '/careers', pageviews: 145, visits: 123 },
{ path: '/contact', pageviews: 112, visits: 98 },
]
const FAKE_EXIT_PAGES = [
{ path: '/pricing', pageviews: 1456, visits: 1234 },
{ path: '/', pageviews: 1234, visits: 1087 },
{ path: '/contact', pageviews: 876, visits: 756 },
{ path: '/products/drop', pageviews: 654, visits: 543 },
{ path: '/products/pulse', pageviews: 567, visits: 478 },
{ path: '/docs/getting-started', pageviews: 432, visits: 367 },
{ path: '/about', pageviews: 345, visits: 289 },
{ path: '/blog/privacy-first-analytics', pageviews: 298, visits: 245 },
{ path: '/careers', pageviews: 234, visits: 198 },
{ path: '/blog/end-to-end-encryption', pageviews: 178, visits: 145 },
]
const FAKE_REFERRERS = [
{ referrer: 'google.com', pageviews: 3421 },
{ referrer: '(direct)', pageviews: 2100 },
{ referrer: 'twitter.com', pageviews: 876 },
{ referrer: 'github.com', pageviews: 654 },
{ referrer: 'reddit.com', pageviews: 432 },
{ referrer: 'producthunt.com', pageviews: 312 },
{ referrer: 'news.ycombinator.com', pageviews: 267 },
{ referrer: 'linkedin.com', pageviews: 198 },
{ referrer: 'duckduckgo.com', pageviews: 112 },
{ referrer: 'dev.to', pageviews: 78 },
]
const FAKE_COUNTRIES = [
{ country: 'CH', pageviews: 2534 },
{ country: 'DE', pageviews: 1856 },
{ country: 'US', pageviews: 1234 },
{ country: 'FR', pageviews: 876 },
{ country: 'GB', pageviews: 654 },
{ country: 'NL', pageviews: 432 },
{ country: 'AT', pageviews: 312 },
{ country: 'SE', pageviews: 198 },
{ country: 'JP', pageviews: 156 },
{ country: 'CA', pageviews: 134 },
]
const FAKE_CITIES = [
{ city: 'Zurich', country: 'CH', pageviews: 1234 },
{ city: 'Geneva', country: 'CH', pageviews: 678 },
{ city: 'Berlin', country: 'DE', pageviews: 567 },
{ city: 'Munich', country: 'DE', pageviews: 432 },
{ city: 'San Francisco', country: 'US', pageviews: 345 },
{ city: 'Paris', country: 'FR', pageviews: 312 },
{ city: 'London', country: 'GB', pageviews: 289 },
{ city: 'Amsterdam', country: 'NL', pageviews: 234 },
{ city: 'Vienna', country: 'AT', pageviews: 198 },
{ city: 'New York', country: 'US', pageviews: 178 },
]
const FAKE_REGIONS = [
{ region: 'Zurich', country: 'CH', pageviews: 1567 },
{ region: 'Geneva', country: 'CH', pageviews: 734 },
{ region: 'Bavaria', country: 'DE', pageviews: 523 },
{ region: 'Berlin', country: 'DE', pageviews: 489 },
{ region: 'California', country: 'US', pageviews: 456 },
{ region: 'Ile-de-France', country: 'FR', pageviews: 345 },
{ region: 'England', country: 'GB', pageviews: 312 },
{ region: 'North Holland', country: 'NL', pageviews: 267 },
{ region: 'Bern', country: 'CH', pageviews: 234 },
{ region: 'New York', country: 'US', pageviews: 198 },
]
const FAKE_BROWSERS = [
{ browser: 'Chrome', pageviews: 5234 },
{ browser: 'Firefox', pageviews: 1518 },
{ browser: 'Safari', pageviews: 987 },
{ browser: 'Edge', pageviews: 456 },
{ browser: 'Brave', pageviews: 178 },
{ browser: 'Arc', pageviews: 59 },
]
const FAKE_OS = [
{ os: 'macOS', pageviews: 3421 },
{ os: 'Windows', pageviews: 2567 },
{ os: 'Linux', pageviews: 1234 },
{ os: 'iOS', pageviews: 756 },
{ os: 'Android', pageviews: 454 },
]
const FAKE_DEVICES = [
{ device: 'Desktop', pageviews: 5876 },
{ device: 'Mobile', pageviews: 1987 },
{ device: 'Tablet', pageviews: 569 },
]
const FAKE_SCREEN_RESOLUTIONS = [
{ screen_resolution: '1920x1080', pageviews: 2345 },
{ screen_resolution: '1440x900', pageviews: 1567 },
{ screen_resolution: '2560x1440', pageviews: 1234 },
{ screen_resolution: '1366x768', pageviews: 876 },
{ screen_resolution: '3840x2160', pageviews: 654 },
{ screen_resolution: '1536x864', pageviews: 432 },
{ screen_resolution: '390x844', pageviews: 312 },
{ screen_resolution: '393x873', pageviews: 234 },
]
// ─── Component ───────────────────────────────────────────────────────
export default function DashboardDemo() {
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>('hour')
const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>('day')
const today = new Date().toISOString().split('T')[0]
const dateRange = { start: today, end: today }
const noop = () => {}
return (
<div className="relative">
{/* Orange glow behind */}
<div className="absolute -inset-8 bg-brand-orange/8 rounded-[2.5rem] blur-3xl" />
{/* Outer frame with showcase bg */}
<div className="relative rounded-3xl border border-white/[0.08] overflow-hidden p-5 sm:p-8 lg:p-10">
<img src="/pulse-showcase-bg.png" alt="" className="absolute inset-0 w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40" />
{/* Inner dashboard — solid background */}
<div className="relative rounded-2xl bg-neutral-950/80 backdrop-blur-sm p-4 sm:p-6">
{/* Dashboard header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<div>
<h2 className="text-xl font-bold text-white">Ciphera</h2>
<p className="text-sm text-neutral-400">ciphera.net</p>
</div>
<div className="flex items-center gap-2 px-3 py-1 bg-green-500/10 rounded-full border border-green-500/20">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500" />
</span>
<span className="text-sm font-medium text-green-400">12 current visitors</span>
</div>
</div>
<div className="px-4 py-2 rounded-lg bg-neutral-900/80 border border-white/[0.08] text-sm text-neutral-300">
Today
</div>
</div>
{/* Chart with stats */}
<div className="mb-6">
<Chart
data={FAKE_DAILY_STATS}
stats={FAKE_STATS}
prevStats={FAKE_PREV_STATS}
interval={todayInterval}
dateRange={dateRange}
period="today"
todayInterval={todayInterval}
setTodayInterval={setTodayInterval}
multiDayInterval={multiDayInterval}
setMultiDayInterval={setMultiDayInterval}
/>
</div>
{/* 2-col grid: Pages + Referrers */}
<div className="grid gap-6 lg:grid-cols-2 mb-6 [&>*]:min-w-0">
<ContentStats
topPages={FAKE_TOP_PAGES}
entryPages={FAKE_ENTRY_PAGES}
exitPages={FAKE_EXIT_PAGES}
domain="ciphera.net"
collectPagePaths={true}
siteId="demo"
dateRange={dateRange}
onFilter={noop}
/>
<TopReferrers
referrers={FAKE_REFERRERS}
collectReferrers={true}
siteId="demo"
dateRange={dateRange}
onFilter={noop}
/>
</div>
{/* 2-col grid: Locations + Tech */}
<div className="grid gap-6 lg:grid-cols-2 [&>*]:min-w-0">
<Locations
countries={FAKE_COUNTRIES}
cities={FAKE_CITIES}
regions={FAKE_REGIONS}
geoDataLevel="full"
siteId="demo"
dateRange={dateRange}
onFilter={noop}
/>
<TechSpecs
browsers={FAKE_BROWSERS}
os={FAKE_OS}
devices={FAKE_DEVICES}
screenResolutions={FAKE_SCREEN_RESOLUTIONS}
collectDeviceInfo={true}
collectScreenResolution={true}
siteId="demo"
dateRange={dateRange}
onFilter={noop}
/>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,169 @@
'use client';
import React, { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { Plus } from '@phosphor-icons/react';
import { cn } from '@/lib/utils';
interface FAQItem {
question: string;
answer: string;
}
interface FAQProps extends React.HTMLAttributes<HTMLElement> {
title?: string;
subtitle?: string;
categories: Record<string, string>;
faqData: Record<string, FAQItem[]>;
}
export const FAQ = ({
title = "FAQs",
subtitle = "Frequently Asked Questions",
categories,
faqData,
className,
...props
}: FAQProps) => {
const categoryKeys = Object.keys(categories);
const [selectedCategory, setSelectedCategory] = useState(categoryKeys[0]);
return (
<section
className={cn(
"relative overflow-hidden bg-background px-4 py-12 text-foreground",
className
)}
{...props}
>
<FAQHeader title={title} subtitle={subtitle} />
<FAQTabs
categories={categories}
selected={selectedCategory}
setSelected={setSelectedCategory}
/>
<FAQList
faqData={faqData}
selected={selectedCategory}
/>
</section>
);
};
const FAQHeader = ({ title, subtitle }: { title: string; subtitle: string }) => (
<div className="relative z-10 flex flex-col items-center justify-center">
<span className="mb-8 bg-gradient-to-r from-primary to-primary/60 bg-clip-text font-medium text-transparent">
{subtitle}
</span>
<h2 className="mb-8 text-5xl font-bold">{title}</h2>
</div>
);
const FAQTabs = ({ categories, selected, setSelected }: { categories: Record<string, string>; selected: string; setSelected: (key: string) => void }) => (
<div className="relative z-10 flex flex-wrap items-center justify-center gap-4">
{Object.entries(categories).map(([key, label]) => (
<button
key={key}
onClick={() => setSelected(key)}
className={cn(
"relative overflow-hidden whitespace-nowrap rounded-md border px-3 py-1.5 text-sm font-medium transition-colors duration-500",
selected === key
? "border-primary text-background"
: "border-border bg-transparent text-muted-foreground hover:text-foreground"
)}
>
<span className="relative z-10">{label}</span>
<AnimatePresence>
{selected === key && (
<motion.span
initial={{ y: "100%" }}
animate={{ y: "0%" }}
exit={{ y: "100%" }}
transition={{ duration: 0.5, ease: "backIn" }}
className="absolute inset-0 z-0 bg-gradient-to-r from-primary to-primary/80"
/>
)}
</AnimatePresence>
</button>
))}
</div>
);
const FAQList = ({ faqData, selected }: { faqData: Record<string, FAQItem[]>; selected: string }) => (
<div className="mx-auto mt-12 max-w-3xl">
<AnimatePresence mode="wait">
{Object.entries(faqData).map(([category, questions]) => {
if (selected === category) {
return (
<motion.div
key={category}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.5, ease: "backIn" }}
className="space-y-4"
>
{questions.map((faq, index) => (
<FAQItemComponent key={index} {...faq} />
))}
</motion.div>
);
}
return null;
})}
</AnimatePresence>
</div>
);
const FAQItemComponent = ({ question, answer }: FAQItem) => {
const [isOpen, setIsOpen] = useState(false);
return (
<motion.div
animate={isOpen ? "open" : "closed"}
className={cn(
"rounded-xl border transition-colors",
isOpen ? "bg-muted/50" : "bg-card"
)}
>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex w-full items-center justify-between gap-4 p-4 text-left"
>
<span
className={cn(
"text-lg font-medium transition-colors",
isOpen ? "text-foreground" : "text-muted-foreground"
)}
>
{question}
</span>
<motion.span
variants={{
open: { rotate: "45deg" },
closed: { rotate: "0deg" },
}}
transition={{ duration: 0.2 }}
>
<Plus
className={cn(
"h-5 w-5 transition-colors",
isOpen ? "text-foreground" : "text-muted-foreground"
)}
/>
</motion.span>
</button>
<motion.div
initial={false}
animate={{
height: isOpen ? "auto" : "0px",
marginBottom: isOpen ? "16px" : "0px"
}}
transition={{ duration: 0.3, ease: "easeInOut" }}
className="overflow-hidden px-4"
>
<p className="text-muted-foreground">{answer}</p>
</motion.div>
</motion.div>
);
};

View File

@@ -0,0 +1,210 @@
'use client'
import { motion } from 'framer-motion'
import { Check } from '@phosphor-icons/react'
import { PulseMockup } from './mockups/pulse-mockup'
import { PulseFeaturesCarousel } from './mockups/pulse-features-carousel'
import { FunnelMockup } from './mockups/funnel-mockup'
import { EmailReportMockup } from './mockups/email-report-mockup'
// Section wrapper component for reuse
function FeatureSection({
id,
heading,
description,
features,
mockup,
reverse = false,
showBg = true,
}: {
id: string
heading: string
description: string
features: string[]
mockup: React.ReactNode
reverse?: boolean
showBg?: boolean
}) {
return (
<section id={id} className="container mx-auto px-6 scroll-mt-28">
<div className={`grid lg:grid-cols-2 gap-12 lg:gap-20 items-center`}>
{/* Text */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className={reverse ? 'lg:order-last' : ''}
>
<h2 className="text-3xl sm:text-4xl md:text-5xl font-bold text-white leading-tight mb-6">
{heading}
</h2>
<p className="text-lg text-neutral-400 leading-relaxed mb-6">
{description}
</p>
<ul className="space-y-3 mb-8">
{features.map((item) => (
<li key={item} className="flex gap-3 text-neutral-300">
<Check weight="bold" className="w-5 h-5 text-brand-orange mt-0.5 shrink-0" />
<span>{item}</span>
</li>
))}
</ul>
</motion.div>
{/* Mockup container */}
<motion.div
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.1 }}
className={`relative ${reverse ? 'lg:order-first' : ''}`}
>
{showBg && <div className="absolute -inset-8 bg-brand-orange/8 rounded-[2.5rem] blur-3xl" />}
<div className={`relative rounded-3xl overflow-hidden border border-white/[0.08] ${showBg ? '' : 'bg-neutral-900/80'}`}>
{showBg && (
<>
<img src="/pulse-showcase-bg.png" alt="" className="absolute inset-0 w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/30" />
</>
)}
<div className="relative">
{mockup}
</div>
</div>
</motion.div>
</div>
</section>
)
}
export default function FeatureSections() {
return (
<div className="py-20 lg:py-32 space-y-28">
{/* Section 1: Dashboard — text left, mockup right */}
<FeatureSection
id="dashboard"
heading="Your traffic, at a glance."
description="Get a clear, real-time overview of your website's performance without the clutter of traditional analytics tools."
features={[
'Live visitor count with real-time updates',
'Hourly, daily, weekly, and monthly trends',
'Referrer sources and UTM campaign tracking',
'Country-level geographic breakdown',
]}
mockup={
<div className="p-6 sm:p-10">
<PulseMockup />
</div>
}
/>
{/* Section 2: Visitors — mockup left, text right */}
<FeatureSection
id="visitors"
heading="Everything you need to know about your visitors."
description="Understand where your traffic comes from, what content resonates, and how visitors interact with your site — all without compromising their privacy."
features={[
'Top pages ranked by views and unique visitors',
'Referrer breakdown with source attribution',
'Browser, OS, and device analytics',
'Peak hours heatmap for optimal publishing',
]}
reverse
mockup={
<div className="p-6 sm:p-10 min-h-[500px] flex items-center">
<div className="w-full">
<PulseFeaturesCarousel />
</div>
</div>
}
/>
{/* Section 3: Funnels — text left, mockup right */}
<FeatureSection
id="funnels"
heading="See where visitors drop off."
description="Build custom conversion funnels to understand your user journey. Identify bottlenecks and optimize your conversion flow."
features={[
'Multi-step funnels with conversion rates',
'Drop-off analysis between each step',
'Conversion trends over time',
'Breakdown by device, country, or referrer',
'Configurable conversion window (up to 90 days)',
]}
mockup={
<div className="p-6 sm:p-10">
<FunnelMockup />
</div>
}
/>
{/* Section 4: Reports — mockup left, text right */}
<FeatureSection
id="reports"
heading="Reports delivered to your inbox."
description="Get automated summaries of your site's performance without logging into a dashboard. Stay informed effortlessly."
features={[
'Daily, weekly, or monthly email summaries',
'Key metrics with period-over-period comparison',
'Top pages, referrers, and country breakdown',
'Webhook delivery for custom integrations',
'Multiple recipients per report',
]}
reverse
mockup={
<div className="p-6 sm:p-10">
<EmailReportMockup />
</div>
}
/>
{/* Section 5: Script — text left, code block right (no showcase bg) */}
<FeatureSection
id="script"
showBg={false}
heading="One script tag. That's it."
description="No npm packages, no build steps, no configuration files. Add a single line to your HTML and start collecting privacy-respecting analytics instantly."
features={[
'Under 2KB gzipped — 20x smaller than Google Analytics',
'Async loading with defer — never blocks rendering',
'Works with any framework or static site',
]}
mockup={
<div className="p-0">
{/* Code block with browser chrome */}
<div className="flex items-center px-4 py-3 bg-neutral-800 border-b border-neutral-800">
<div className="flex gap-2">
<div className="w-3 h-3 rounded-full bg-red-500/20" />
<div className="w-3 h-3 rounded-full bg-yellow-500/20" />
<div className="w-3 h-3 rounded-full bg-green-500/20" />
</div>
<span className="ml-4 text-xs text-neutral-400 font-mono">index.html</span>
</div>
<pre className="p-6 overflow-x-auto">
<code className="font-mono text-sm text-neutral-300">
<span className="text-neutral-500">{'<!-- Add before </head> -->'}</span>{'\n'}
<span className="text-blue-400">{'<'}</span>
<span className="text-blue-400">script</span>{'\n'}
{' '}<span className="text-sky-300">defer</span>{'\n'}
{' '}<span className="text-sky-300">data-domain</span>=<span className="text-orange-300">&quot;yoursite.com&quot;</span>{'\n'}
{' '}<span className="text-sky-300">src</span>=<span className="text-orange-300">&quot;https://pulse.ciphera.net/js/script.js&quot;</span>{'\n'}
<span className="text-blue-400">{'>'}</span>
<span className="text-blue-400">{'</'}</span>
<span className="text-blue-400">script</span>
<span className="text-blue-400">{'>'}</span>
</code>
</pre>
<div className="flex items-center gap-4 px-6 py-3 border-t border-neutral-800 text-xs text-neutral-500">
<span>1.6 KB gzipped</span>
<span className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-green-500" />
Non-blocking, async
</span>
</div>
</div>
}
/>
</div>
)
}

View File

@@ -0,0 +1,309 @@
'use client';
import React from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { Button } from '@/components/ui/button-website';
import { cn } from '@/lib/utils';
import { MenuToggleIcon } from '@/components/ui/menu-toggle-icon';
import { createPortal } from 'react-dom';
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from '@/components/ui/navigation-menu';
import { LucideIcon } from 'lucide-react';
import {
BarChart3,
Eye,
Funnel,
Send,
FileText,
Puzzle,
HelpCircle,
} from 'lucide-react';
type LinkItem = {
title: string;
href: string;
icon?: LucideIcon;
description?: string;
};
const featureLinks: LinkItem[] = [
{
title: 'Dashboard',
href: '/features#dashboard',
icon: BarChart3,
description: 'Real-time traffic overview',
},
{
title: 'Visitor Insights',
href: '/features#visitors',
icon: Eye,
description: 'Browser, device & geo data',
},
{
title: 'Conversion Funnels',
href: '/features#funnels',
icon: Funnel,
description: 'Multi-step drop-off analysis',
},
{
title: 'Email Reports',
href: '/features#reports',
icon: Send,
description: 'Scheduled inbox summaries',
},
];
const resourceLinks: LinkItem[] = [
{
title: 'Installation',
href: '/installation',
icon: FileText,
description: 'Setup guides & code snippets',
},
{
title: 'Integrations',
href: '/integrations',
icon: Puzzle,
description: '75+ framework guides',
},
{
title: 'FAQ',
href: '/faq',
icon: HelpCircle,
description: 'Common questions answered',
},
];
export function Header() {
const [open, setOpen] = React.useState(false);
const scrolled = useScroll(10);
React.useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [open]);
return (
<header
className={cn('sticky top-0 z-50 w-full border-b border-transparent', {
'border-white/[0.06]': scrolled,
})}
>
<div className={cn("absolute inset-0 -z-10 transition-opacity duration-300", scrolled ? "opacity-100 backdrop-blur-xl bg-neutral-950/60 supports-[backdrop-filter]:bg-neutral-950/50" : "opacity-0")} />
<nav className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-6 my-3">
<div className="flex items-center gap-5">
<Link href="/" className="hover:bg-accent rounded-md p-2 flex items-center gap-2">
<Image
src="/pulse_icon_no_margins.png"
alt="Pulse"
width={36}
height={36}
priority
className="object-contain w-8 h-8"
unoptimized
/>
<span className="text-xl font-bold text-foreground tracking-tight">
Pulse
</span>
</Link>
<NavigationMenu className="hidden md:flex">
<NavigationMenuList>
{/* Features dropdown */}
<NavigationMenuItem>
<NavigationMenuTrigger className="bg-transparent">Features</NavigationMenuTrigger>
<NavigationMenuContent className="bg-transparent p-1 pr-1.5">
<ul className="grid w-[32rem] grid-cols-2 gap-2 rounded-md border border-white/[0.06] bg-white/[0.04] p-2">
{featureLinks.map((item, i) => (
<li key={i}>
<ListItem title={item.title} href={item.href} icon={item.icon} description={item.description} />
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
{/* Resources dropdown */}
<NavigationMenuItem>
<NavigationMenuTrigger className="bg-transparent">Resources</NavigationMenuTrigger>
<NavigationMenuContent className="bg-transparent p-1 pr-1.5 pb-1.5">
<div className="grid w-[32rem] grid-cols-2 gap-2">
<ul className="space-y-2 rounded-md border border-white/[0.06] bg-white/[0.04] p-2">
{resourceLinks.map((item, i) => (
<li key={i}>
<ListItem {...item} />
</li>
))}
</ul>
<div className="flex flex-col justify-center gap-3 p-4">
<p className="text-sm font-medium text-foreground">Need help?</p>
<p className="text-xs text-muted-foreground leading-relaxed">
Questions about setup, integrations, or billing we typically respond within 24-48 hours.
</p>
<a href="mailto:support@ciphera.net" className="text-sm font-medium text-brand-orange hover:underline">
support@ciphera.net &rarr;
</a>
</div>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
{/* Pricing standalone link */}
<NavigationMenuItem>
<NavigationMenuLink asChild>
<Link href="/pricing" className="group inline-flex h-9 w-max items-center justify-center rounded-md bg-transparent px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none">
Pricing
</Link>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
<div className="hidden items-center gap-2 md:flex">
<Button variant="outline" asChild>
<a href="https://pulse.ciphera.net">Sign In</a>
</Button>
<Button asChild>
<a href="https://pulse.ciphera.net">Get Started</a>
</Button>
</div>
<div className="flex items-center gap-2 md:hidden">
<Button
size="icon"
variant="outline"
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-controls="mobile-menu"
aria-label="Toggle menu"
>
<MenuToggleIcon open={open} className="size-5" duration={300} />
</Button>
</div>
</nav>
<MobileMenu open={open} className="flex flex-col justify-between gap-2 overflow-y-auto">
<NavigationMenu className="max-w-full">
<div className="flex w-full flex-col gap-y-2">
<span className="text-sm">Features</span>
{featureLinks.map((link) => (
<ListItem key={link.title} title={link.title} href={link.href} icon={link.icon} description={link.description} />
))}
<span className="text-sm">Resources</span>
{resourceLinks.map((link) => (
<ListItem key={link.title} {...link} />
))}
<Link
href="/pricing"
className="flex flex-row gap-x-2 rounded-sm p-2 transition-colors hover:bg-white/[0.06]"
>
<div className="flex aspect-square size-12 items-center justify-center rounded-md border border-white/[0.08] bg-white/[0.05] shadow-sm p-2">
<BarChart3 className="text-foreground size-5" />
</div>
<div className="flex flex-col items-start justify-center">
<span className="text-sm font-medium">Pricing</span>
<span className="text-muted-foreground text-xs">Plans & billing</span>
</div>
</Link>
</div>
</NavigationMenu>
<div className="flex flex-col gap-2">
<Button variant="outline" className="w-full bg-transparent" asChild>
<a href="https://pulse.ciphera.net">
Sign In
</a>
</Button>
<Button className="w-full" asChild>
<a href="https://pulse.ciphera.net">
Get Started
</a>
</Button>
</div>
</MobileMenu>
</header>
);
}
type MobileMenuProps = React.ComponentProps<'div'> & {
open: boolean;
};
function MobileMenu({ open, children, className, ...props }: MobileMenuProps) {
if (!open || typeof window === 'undefined') return null;
return createPortal(
<div
id="mobile-menu"
className={cn(
'bg-background/95 supports-[backdrop-filter]:bg-background/50 backdrop-blur-lg',
'fixed top-16 right-0 bottom-0 left-0 z-40 flex flex-col overflow-hidden border-y md:hidden',
)}
>
<div
data-slot={open ? 'open' : 'closed'}
className={cn(
'data-[slot=open]:animate-in data-[slot=open]:zoom-in-95 ease-out',
'size-full p-4',
className,
)}
{...props}
>
{children}
</div>
</div>,
document.body,
);
}
function ListItem({
title,
description,
icon: Icon,
className,
href,
...props
}: React.ComponentProps<typeof NavigationMenuLink> & LinkItem) {
return (
<NavigationMenuLink className={cn('w-full flex flex-row gap-x-2 data-[active=true]:focus:bg-white/[0.06] data-[active=true]:hover:bg-white/[0.06] data-[active=true]:text-accent-foreground hover:bg-white/[0.06] hover:text-accent-foreground focus:bg-white/[0.06] focus:text-accent-foreground rounded-sm p-2 transition-colors', className)} {...props} asChild>
<Link href={href || '#'}>
<div className="flex aspect-square size-12 items-center justify-center rounded-md border border-white/[0.08] bg-white/[0.05] shadow-sm p-2">
{Icon ? (
<Icon className="text-foreground size-5" />
) : null}
</div>
<div className="flex flex-col items-start justify-center">
<span className="text-sm font-medium">{title}</span>
<span className="text-muted-foreground text-xs">{description}</span>
</div>
</Link>
</NavigationMenuLink>
);
}
function useScroll(threshold: number) {
const [scrolled, setScrolled] = React.useState(false);
const onScroll = React.useCallback(() => {
setScrolled(window.scrollY > threshold);
}, [threshold]);
React.useEffect(() => {
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, [onScroll]);
React.useEffect(() => {
onScroll();
}, [onScroll]);
return scrolled;
}

View File

@@ -0,0 +1,112 @@
'use client'
import { FAQ } from '@/components/marketing/FAQ'
const categories: Record<string, string> = {
general: "General",
setup: "Setup",
privacy: "Privacy & Compliance",
technical: "Technical",
}
const faqData: Record<string, { question: string; answer: string }[]> = {
general: [
{
question: "What is Pulse?",
answer: "Pulse is a privacy-first website analytics platform by Ciphera. It tracks pageviews, unique visitors, referrers, and geographic data without using cookies, fingerprinting, or collecting any personal data. It's a privacy-respecting alternative to Google Analytics.",
},
{
question: "Is Pulse free?",
answer: "Yes, Pulse is free for personal websites. We plan to offer a paid Pro tier for teams and high-traffic sites in the future, but the free tier will always be available.",
},
{
question: "Can I migrate from Google Analytics?",
answer: "Pulse is not a drop-in replacement for Google Analytics — it's fundamentally different by design. It doesn't track individual users or sessions, so historical GA data can't be imported. However, you can run both side by side during a transition period.",
},
{
question: "Is Pulse open source?",
answer: "The Pulse client — dashboard and tracking script — are open source and available on GitHub. You can inspect every line of code that runs on your site and verify our privacy claims.",
},
{
question: "How is Pulse different from Plausible or Fathom?",
answer: "Pulse shares the privacy-first philosophy with Plausible and Fathom, but it's built on Swiss infrastructure with Swiss data protection laws. The client — dashboard and tracking script — are open source, and Pulse is part of the Ciphera ecosystem, giving you a unified privacy-first stack.",
},
],
setup: [
{
question: "How do I install Pulse?",
answer: "Add a single script tag to your site's <head> section. That's it. No npm packages, no build steps, no configuration files. The script is under 2KB gzipped and loads asynchronously.",
},
{
question: "Does Pulse work with my framework?",
answer: "Yes. Pulse works with any website or framework: plain HTML, React, Next.js, Vue, Nuxt, Svelte, WordPress, Shopify, and more. If it renders HTML, Pulse works with it.",
},
{
question: "How do I verify Pulse is working?",
answer: "After adding the script tag, visit your site and check the Pulse dashboard. You should see your visit appear in real-time within seconds. The dashboard shows a live visitor count and updates every few seconds.",
},
{
question: "Can I track multiple websites?",
answer: "Yes. Each website gets its own dashboard. You can add as many sites as you need from the Pulse dashboard by adding the script tag with a different data-domain attribute.",
},
{
question: "Does Pulse slow down my website?",
answer: "No. The Pulse script is under 2KB gzipped — about 20x smaller than Google Analytics. It loads asynchronously with the defer attribute, meaning it never blocks page rendering or affects your Core Web Vitals scores.",
},
],
privacy: [
{
question: "Do I need a cookie consent banner for Pulse?",
answer: "No. Because Pulse doesn't use cookies, fingerprinting, or any form of persistent identifier, it's exempt from ePrivacy cookie consent requirements. You can use Pulse without any consent banner.",
},
{
question: "Is Pulse GDPR compliant?",
answer: "Yes, by architecture — not by configuration. Pulse doesn't collect any personal data as defined by GDPR Article 4. There are no data subjects in the dataset, so DSAR requests don't apply. No DPA is required.",
},
{
question: "What happens to IP addresses?",
answer: "IP addresses are used only at the network edge for country-level geolocation. They are immediately discarded after the geo lookup — never stored, never logged, never written to disk. We can't retrieve them even if asked.",
},
{
question: "Where is my analytics data stored?",
answer: "All data is processed and stored on Swiss infrastructure, protected by the Swiss Federal Act on Data Protection (FADP). Data never leaves Swiss jurisdiction.",
},
{
question: "Can Pulse identify individual users?",
answer: "No. Pulse is architecturally incapable of identifying individual users. Each pageview is treated as an independent, anonymous event. There are no user IDs, session IDs, or any form of persistent tracking.",
},
],
technical: [
{
question: "How does Pulse count unique visitors without cookies?",
answer: "Pulse uses a privacy-safe hashing method that generates a daily rotating identifier from non-personal data points. This allows approximate unique visitor counts without tracking individuals across sessions or days.",
},
{
question: "Does Pulse have an API?",
answer: "Yes. Pulse provides a REST API for programmatic access to your analytics data. You can use it to build custom dashboards, integrate with other tools, or export your data.",
},
{
question: "What metrics does Pulse track?",
answer: "Pulse tracks pageviews, unique visitors, bounce rate, visit duration, referrer sources, UTM parameters, device type, browser, operating system, and country-level geolocation.",
},
{
question: "Can I export my data?",
answer: "Yes. The dashboard includes an export feature that lets you download your analytics data. You can also use the API for automated exports.",
},
{
question: "Does Pulse support custom events?",
answer: "Custom event tracking is on our roadmap. Currently, Pulse focuses on pageview analytics. We plan to add lightweight custom event support that maintains our zero-personal-data architecture.",
},
],
}
export default function PulseFAQ() {
return (
<FAQ
title="Frequently Asked Questions"
subtitle="Everything you need to know about Pulse"
categories={categories}
faqData={faqData}
/>
)
}

View File

@@ -0,0 +1,86 @@
'use client'
export function EmailReportMockup() {
return (
<div className="relative w-full max-w-[460px] mx-auto">
<div className="rounded-xl border border-white/[0.08] bg-neutral-900/90 shadow-2xl overflow-hidden">
{/* Pulse logo header */}
<div className="px-6 pt-5 pb-3">
<div className="flex items-center gap-2.5 mb-3">
<svg className="w-5 h-5 text-neutral-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2" /></svg>
<span className="text-base font-bold text-white">Pulse</span>
</div>
<div className="h-[3px] bg-brand-orange rounded-full" />
</div>
{/* Report content */}
<div className="px-6 pb-5">
<div className="rounded-xl bg-neutral-800/50 border border-neutral-700/40 p-5">
<h3 className="text-lg font-bold text-white mb-0.5">ciphera.net</h3>
<p className="text-xs text-neutral-500 mb-3">Daily summary report &middot; 19 Mar 2026</p>
<p className="text-sm text-brand-orange font-semibold mb-4">Traffic down 6% compared to yesterday</p>
{/* Stats grid */}
<div className="grid grid-cols-4 gap-2 mb-5">
{[
{ label: 'PAGEVIEWS', value: '323', change: '2%', down: true },
{ label: 'VISITORS', value: '207', change: '6%', down: true },
{ label: 'BOUNCE', value: '97%', change: '0%', down: false },
{ label: 'DURATION', value: '3m 18s', change: '7%', down: false },
].map((stat) => (
<div key={stat.label} className="rounded-lg bg-neutral-900/80 border border-neutral-700/30 px-1.5 py-2.5 text-center">
<p className="text-[7px] text-neutral-500 uppercase tracking-wider mb-1">{stat.label}</p>
<p className="text-sm font-bold text-white leading-none mb-1">{stat.value}</p>
<p className={`text-[8px] font-semibold ${stat.down ? 'text-red-400' : 'text-green-400'}`}>
{stat.down ? '\u25BC' : '\u25B2'} {stat.change}
</p>
</div>
))}
</div>
{/* Divider */}
<div className="border-t border-neutral-700/40 mb-3" />
{/* Top Pages */}
<h4 className="text-[10px] text-brand-orange font-bold uppercase tracking-wider mb-2">Top Pages</h4>
<div className="flex items-center justify-between text-[8px] text-neutral-500 uppercase tracking-wider mb-1.5 px-0.5">
<span>Page</span>
<span>Views</span>
</div>
<div className="space-y-0.5">
{[
{ page: '/', views: 100 },
{ page: '/products/drop', views: 96 },
{ page: '/pricing', views: 42 },
].map((row) => (
<div key={row.page}>
<div className="flex items-center gap-3">
<div className="relative flex-1 h-[20px]">
<div
className="absolute inset-y-0 left-0 rounded-md bg-brand-orange/20"
style={{ width: `${(row.views / 100) * 75}%` }}
/>
</div>
<span className="text-xs text-neutral-400 tabular-nums w-7 text-right shrink-0">{row.views}</span>
</div>
<span className="text-[11px] text-neutral-300 ml-0.5">{row.page}</span>
</div>
))}
</div>
</div>
{/* Schedule indicator */}
<div className="flex items-center justify-between mt-3 px-1 text-[10px] text-neutral-500">
<span>Delivered every day at 09:00</span>
<span className="flex items-center gap-1">
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
Sent
</span>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,935 @@
"use client";
import { motion, useSpring, useTransform } from "motion/react";
import {
type CSSProperties,
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
// ─── Utils ───────────────────────────────────────────────────────────────────
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// ─── PatternLines ────────────────────────────────────────────────────────────
export interface PatternLinesProps {
id: string;
width?: number;
height?: number;
stroke?: string;
strokeWidth?: number;
orientation?: ("diagonal" | "horizontal" | "vertical")[];
background?: string;
}
export function PatternLines({
id,
width = 6,
height = 6,
stroke = "var(--chart-line-primary)",
strokeWidth = 1,
orientation = ["diagonal"],
background,
}: PatternLinesProps) {
const paths: string[] = [];
for (const o of orientation) {
if (o === "diagonal") {
paths.push(`M0,${height}l${width},${-height}`);
paths.push(`M${-width / 4},${height / 4}l${width / 2},${-height / 2}`);
paths.push(
`M${(3 * width) / 4},${height + height / 4}l${width / 2},${-height / 2}`
);
} else if (o === "horizontal") {
paths.push(`M0,${height / 2}l${width},0`);
} else if (o === "vertical") {
paths.push(`M${width / 2},0l0,${height}`);
}
}
return (
<pattern
id={id}
width={width}
height={height}
patternUnits="userSpaceOnUse"
>
{background && (
<rect width={width} height={height} fill={background} />
)}
<path
d={paths.join(" ")}
fill="none"
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinecap="square"
/>
</pattern>
);
}
PatternLines.displayName = "PatternLines";
// ─── Types ───────────────────────────────────────────────────────────────────
export interface FunnelGradientStop {
offset: string | number;
color: string;
}
export interface FunnelStage {
label: string;
value: number;
displayValue?: string;
color?: string;
gradient?: FunnelGradientStop[];
}
export interface FunnelChartProps {
data: FunnelStage[];
orientation?: "horizontal" | "vertical";
color?: string;
layers?: number;
className?: string;
style?: CSSProperties;
showPercentage?: boolean;
showValues?: boolean;
showLabels?: boolean;
hoveredIndex?: number | null;
onHoverChange?: (index: number | null) => void;
formatPercentage?: (pct: number) => string;
formatValue?: (value: number) => string;
staggerDelay?: number;
gap?: number;
renderPattern?: (id: string, color: string) => ReactNode;
edges?: "curved" | "straight";
labelLayout?: "spread" | "grouped";
labelOrientation?: "vertical" | "horizontal";
labelAlign?: "center" | "start" | "end";
grid?:
| boolean
| {
bands?: boolean;
bandColor?: string;
lines?: boolean;
lineColor?: string;
lineOpacity?: number;
lineWidth?: number;
};
}
// ─── Defaults ────────────────────────────────────────────────────────────────
const fmtPct = (p: number) => `${Math.round(p)}%`;
const fmtVal = (v: number) => v.toLocaleString("en-US");
const springConfig = { stiffness: 120, damping: 20, mass: 1 };
const hoverSpring = { stiffness: 300, damping: 24 };
// ─── SVG Helpers ─────────────────────────────────────────────────────────────
function hSegmentPath(
normStart: number,
normEnd: number,
segW: number,
H: number,
layerScale: number,
straight = false
) {
const my = H / 2;
const h0 = normStart * H * 0.44 * layerScale;
const h1 = normEnd * H * 0.44 * layerScale;
if (straight) {
return `M 0 ${my - h0} L ${segW} ${my - h1} L ${segW} ${my + h1} L 0 ${my + h0} Z`;
}
const cx = segW * 0.55;
const top = `M 0 ${my - h0} C ${cx} ${my - h0}, ${segW - cx} ${my - h1}, ${segW} ${my - h1}`;
const bot = `L ${segW} ${my + h1} C ${segW - cx} ${my + h1}, ${cx} ${my + h0}, 0 ${my + h0}`;
return `${top} ${bot} Z`;
}
function vSegmentPath(
normStart: number,
normEnd: number,
segH: number,
W: number,
layerScale: number,
straight = false
) {
const mx = W / 2;
const w0 = normStart * W * 0.44 * layerScale;
const w1 = normEnd * W * 0.44 * layerScale;
if (straight) {
return `M ${mx - w0} 0 L ${mx - w1} ${segH} L ${mx + w1} ${segH} L ${mx + w0} 0 Z`;
}
const cy = segH * 0.55;
const left = `M ${mx - w0} 0 C ${mx - w0} ${cy}, ${mx - w1} ${segH - cy}, ${mx - w1} ${segH}`;
const right = `L ${mx + w1} ${segH} C ${mx + w1} ${segH - cy}, ${mx + w0} ${cy}, ${mx + w0} 0`;
return `${left} ${right} Z`;
}
// ─── Animated Ring ───────────────────────────────────────────────────────────
function HRing({
d,
color,
fill,
opacity,
hovered,
ringIndex,
totalRings,
}: {
d: string;
color: string;
fill?: string;
opacity: number;
hovered: boolean;
ringIndex: number;
totalRings: number;
}) {
const extraScale = 1 + (ringIndex / Math.max(totalRings - 1, 1)) * 0.12;
const ringSpring = {
stiffness: 300 - ringIndex * 60,
damping: 24 - ringIndex * 3,
};
const scaleY = useSpring(1, ringSpring);
useEffect(() => {
scaleY.set(hovered ? extraScale : 1);
}, [hovered, scaleY, extraScale]);
return (
<motion.path
d={d}
fill={fill ?? color}
opacity={opacity}
style={{ scaleY, transformOrigin: "center center" }}
/>
);
}
function VRing({
d,
color,
fill,
opacity,
hovered,
ringIndex,
totalRings,
}: {
d: string;
color: string;
fill?: string;
opacity: number;
hovered: boolean;
ringIndex: number;
totalRings: number;
}) {
const extraScale = 1 + (ringIndex / Math.max(totalRings - 1, 1)) * 0.12;
const ringSpring = {
stiffness: 300 - ringIndex * 60,
damping: 24 - ringIndex * 3,
};
const scaleX = useSpring(1, ringSpring);
useEffect(() => {
scaleX.set(hovered ? extraScale : 1);
}, [hovered, scaleX, extraScale]);
return (
<motion.path
d={d}
fill={fill ?? color}
opacity={opacity}
style={{ scaleX, transformOrigin: "center center" }}
/>
);
}
// ─── Animated Segments ───────────────────────────────────────────────────────
function HSegment({
index,
normStart,
normEnd,
segW,
fullH,
color,
layers,
staggerDelay,
hovered,
dimmed,
renderPattern,
straight,
gradientStops,
}: {
index: number;
normStart: number;
normEnd: number;
segW: number;
fullH: number;
color: string;
layers: number;
staggerDelay: number;
hovered: boolean;
dimmed: boolean;
renderPattern?: (id: string, color: string) => ReactNode;
straight: boolean;
gradientStops?: FunnelGradientStop[];
}) {
const patternId = `funnel-h-pattern-${index}`;
const gradientId = `funnel-h-grad-${index}`;
const growProgress = useSpring(0, springConfig);
const entranceScaleX = useTransform(growProgress, [0, 1], [0, 1]);
const entranceScaleY = useTransform(growProgress, [0, 1], [0, 1]);
const dimOpacity = useSpring(1, hoverSpring);
useEffect(() => {
dimOpacity.set(dimmed ? 0.4 : 1);
}, [dimmed, dimOpacity]);
useEffect(() => {
const timeout = setTimeout(
() => growProgress.set(1),
index * staggerDelay * 1000
);
return () => clearTimeout(timeout);
}, [growProgress, index, staggerDelay]);
const rings = Array.from({ length: layers }, (_, l) => {
const scale = 1 - (l / layers) * 0.35;
const opacity = 0.18 + (l / (layers - 1 || 1)) * 0.65;
return {
d: hSegmentPath(normStart, normEnd, segW, fullH, scale, straight),
opacity,
};
});
return (
<motion.div
className="pointer-events-none relative shrink-0 overflow-visible"
style={{
width: segW,
height: fullH,
zIndex: hovered ? 10 : 1,
opacity: dimOpacity,
}}
>
<motion.div
className="absolute inset-0 overflow-visible"
style={{
scaleX: entranceScaleX,
scaleY: entranceScaleY,
transformOrigin: "left center",
}}
>
<svg
aria-hidden="true"
className="absolute inset-0 h-full w-full overflow-visible"
preserveAspectRatio="none"
role="presentation"
viewBox={`0 0 ${segW} ${fullH}`}
>
<defs>
{gradientStops && (
<linearGradient id={gradientId} x1="0" x2="1" y1="0" y2="0">
{gradientStops.map((stop) => (
<stop
key={`${stop.offset}-${stop.color}`}
offset={
typeof stop.offset === "number"
? `${stop.offset * 100}%`
: stop.offset
}
stopColor={stop.color}
/>
))}
</linearGradient>
)}
{renderPattern?.(patternId, color)}
</defs>
{rings.map((r, i) => {
const isInnermost = i === rings.length - 1;
let ringFill: string | undefined;
if (isInnermost && renderPattern) {
ringFill = `url(#${patternId})`;
} else if (isInnermost && gradientStops) {
ringFill = `url(#${gradientId})`;
}
return (
<HRing
color={color}
d={r.d}
fill={ringFill}
hovered={hovered}
key={`h-ring-${r.opacity.toFixed(2)}`}
opacity={r.opacity}
ringIndex={i}
totalRings={layers}
/>
);
})}
</svg>
</motion.div>
</motion.div>
);
}
function VSegment({
index,
normStart,
normEnd,
segH,
fullW,
color,
layers,
staggerDelay,
hovered,
dimmed,
renderPattern,
straight,
gradientStops,
}: {
index: number;
normStart: number;
normEnd: number;
segH: number;
fullW: number;
color: string;
layers: number;
staggerDelay: number;
hovered: boolean;
dimmed: boolean;
renderPattern?: (id: string, color: string) => ReactNode;
straight: boolean;
gradientStops?: FunnelGradientStop[];
}) {
const patternId = `funnel-v-pattern-${index}`;
const gradientId = `funnel-v-grad-${index}`;
const growProgress = useSpring(0, springConfig);
const entranceScaleY = useTransform(growProgress, [0, 1], [0, 1]);
const entranceScaleX = useTransform(growProgress, [0, 1], [0, 1]);
const dimOpacity = useSpring(1, hoverSpring);
useEffect(() => {
dimOpacity.set(dimmed ? 0.4 : 1);
}, [dimmed, dimOpacity]);
useEffect(() => {
const timeout = setTimeout(
() => growProgress.set(1),
index * staggerDelay * 1000
);
return () => clearTimeout(timeout);
}, [growProgress, index, staggerDelay]);
const rings = Array.from({ length: layers }, (_, l) => {
const scale = 1 - (l / layers) * 0.35;
const opacity = 0.18 + (l / (layers - 1 || 1)) * 0.65;
return {
d: vSegmentPath(normStart, normEnd, segH, fullW, scale, straight),
opacity,
};
});
return (
<motion.div
className="pointer-events-none relative shrink-0 overflow-visible"
style={{
width: fullW,
height: segH,
zIndex: hovered ? 10 : 1,
opacity: dimOpacity,
}}
>
<motion.div
className="absolute inset-0 overflow-visible"
style={{
scaleY: entranceScaleY,
scaleX: entranceScaleX,
transformOrigin: "center top",
}}
>
<svg
aria-hidden="true"
className="absolute inset-0 h-full w-full overflow-visible"
preserveAspectRatio="none"
role="presentation"
viewBox={`0 0 ${fullW} ${segH}`}
>
<defs>
{gradientStops && (
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
{gradientStops.map((stop) => (
<stop
key={`${stop.offset}-${stop.color}`}
offset={
typeof stop.offset === "number"
? `${stop.offset * 100}%`
: stop.offset
}
stopColor={stop.color}
/>
))}
</linearGradient>
)}
{renderPattern?.(patternId, color)}
</defs>
{rings.map((r, i) => {
const isInnermost = i === rings.length - 1;
let ringFill: string | undefined;
if (isInnermost && renderPattern) {
ringFill = `url(#${patternId})`;
} else if (isInnermost && gradientStops) {
ringFill = `url(#${gradientId})`;
}
return (
<VRing
color={color}
d={r.d}
fill={ringFill}
hovered={hovered}
key={`v-ring-${r.opacity.toFixed(2)}`}
opacity={r.opacity}
ringIndex={i}
totalRings={layers}
/>
);
})}
</svg>
</motion.div>
</motion.div>
);
}
// ─── Label Overlay ───────────────────────────────────────────────────────────
function SegmentLabel({
stage,
pct,
isHorizontal,
showValues,
showPercentage,
showLabels,
formatPercentage,
formatValue,
index,
staggerDelay,
layout = "spread",
orientation,
align = "center",
}: {
stage: FunnelStage;
pct: number;
isHorizontal: boolean;
showValues: boolean;
showPercentage: boolean;
showLabels: boolean;
formatPercentage: (p: number) => string;
formatValue: (v: number) => string;
index: number;
staggerDelay: number;
layout?: "spread" | "grouped";
orientation?: "vertical" | "horizontal";
align?: "center" | "start" | "end";
}) {
const display = stage.displayValue ?? formatValue(stage.value);
const valueEl = showValues && (
<span className="whitespace-nowrap font-semibold text-foreground text-sm">
{display}
</span>
);
const pctEl = showPercentage && (
<span className="rounded-full bg-foreground px-3 py-1 font-bold text-background text-xs shadow-sm">
{formatPercentage(pct)}
</span>
);
const labelEl = showLabels && (
<span className="whitespace-nowrap font-medium text-muted-foreground text-xs">
{stage.label}
</span>
);
if (layout === "spread") {
return (
<motion.div
animate={{ opacity: 1 }}
className={cn(
"absolute inset-0 flex",
isHorizontal ? "flex-col items-center" : "flex-row items-center"
)}
initial={{ opacity: 0 }}
transition={{
delay: index * staggerDelay + 0.25,
duration: 0.35,
ease: "easeOut",
}}
>
{isHorizontal ? (
<>
<div className="flex h-[16%] items-end justify-center pb-1">
{valueEl}
</div>
<div className="flex flex-1 items-center justify-center">
{pctEl}
</div>
<div className="flex h-[16%] items-start justify-center pt-1">
{labelEl}
</div>
</>
) : (
<>
<div className="flex w-[22%] items-center justify-end pr-2">
{valueEl}
</div>
<div className="flex flex-1 items-center justify-center">
{pctEl}
</div>
<div className="flex w-[22%] items-center justify-start pl-2">
{labelEl}
</div>
</>
)}
</motion.div>
);
}
// Grouped layout
const resolvedOrientation =
orientation ?? (isHorizontal ? "vertical" : "horizontal");
const isVerticalStack = resolvedOrientation === "vertical";
const justifyMap = {
start: "justify-start",
center: "justify-center",
end: "justify-end",
} as const;
const itemsMap = {
start: "items-start",
center: "items-center",
end: "items-end",
} as const;
return (
<motion.div
animate={{ opacity: 1 }}
className={cn(
"absolute inset-0 flex",
isHorizontal
? cn("flex-col items-center", justifyMap[align])
: cn("flex-row items-center", justifyMap[align])
)}
initial={{ opacity: 0 }}
style={{
padding: isHorizontal ? "8% 0" : "0 8%",
}}
transition={{
delay: index * staggerDelay + 0.25,
duration: 0.35,
ease: "easeOut",
}}
>
<div
className={cn(
"flex gap-1.5",
isVerticalStack
? cn("flex-col", itemsMap[isHorizontal ? "center" : align])
: cn("flex-row", itemsMap.center)
)}
>
{valueEl}
{pctEl}
{labelEl}
</div>
</motion.div>
);
}
// ─── FunnelChart ─────────────────────────────────────────────────────────────
export function FunnelChart({
data,
orientation = "horizontal",
color = "var(--chart-1)",
layers = 3,
className,
style,
showPercentage = true,
showValues = true,
showLabels = true,
hoveredIndex: hoveredIndexProp,
onHoverChange,
formatPercentage = fmtPct,
formatValue = fmtVal,
staggerDelay = 0.12,
gap = 4,
renderPattern,
edges = "curved",
labelLayout = "spread",
labelOrientation,
labelAlign = "center",
grid: gridProp = false,
}: FunnelChartProps) {
const ref = useRef<HTMLDivElement>(null);
const [sz, setSz] = useState({ w: 0, h: 0 });
const [internalHoveredIndex, setInternalHoveredIndex] = useState<
number | null
>(null);
const isControlled = hoveredIndexProp !== undefined;
const hoveredIndex = isControlled ? hoveredIndexProp : internalHoveredIndex;
const setHoveredIndex = useCallback(
(index: number | null) => {
if (isControlled) {
onHoverChange?.(index);
} else {
setInternalHoveredIndex(index);
}
},
[isControlled, onHoverChange]
);
const measure = useCallback(() => {
if (!ref.current) return;
const { width: w, height: h } = ref.current.getBoundingClientRect();
if (w > 0 && h > 0) setSz({ w, h });
}, []);
useEffect(() => {
measure();
const ro = new ResizeObserver(measure);
if (ref.current) ro.observe(ref.current);
return () => ro.disconnect();
}, [measure]);
if (!data.length) return null;
const first = data[0];
if (!first) return null;
const max = first.value;
const n = data.length;
const norms = data.map((d) => d.value / max);
const horiz = orientation === "horizontal";
const { w: W, h: H } = sz;
const totalGap = gap * (n - 1);
const segW = (W - (horiz ? totalGap : 0)) / n;
const segH = (H - (horiz ? 0 : totalGap)) / n;
// Grid config
const gridEnabled = gridProp !== false;
const gridCfg = typeof gridProp === "object" ? gridProp : {};
const showBands = gridEnabled && (gridCfg.bands ?? true);
const bandColor = gridCfg.bandColor ?? "var(--color-muted)";
const showGridLines = gridEnabled && (gridCfg.lines ?? true);
const gridLineColor = gridCfg.lineColor ?? "var(--chart-grid)";
const gridLineOpacity = gridCfg.lineOpacity ?? 1;
const gridLineWidth = gridCfg.lineWidth ?? 1;
return (
<div
className={cn("relative w-full select-none overflow-visible", className)}
ref={ref}
style={{
aspectRatio: horiz ? "2.2 / 1" : "1 / 1.8",
...style,
}}
>
{W > 0 && H > 0 && (
<>
{/* Grid background bands */}
{gridEnabled && (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full"
preserveAspectRatio="none"
role="presentation"
viewBox={`0 0 ${W} ${H}`}
>
{showBands &&
data.map((stage, i) => {
if (i % 2 !== 0) return null;
if (horiz) {
const x = (segW + gap) * i;
return (
<rect
fill={bandColor}
height={H}
key={`band-${stage.label}`}
width={segW}
x={x}
y={0}
/>
);
}
const y = (segH + gap) * i;
return (
<rect
fill={bandColor}
height={segH}
key={`band-${stage.label}`}
width={W}
x={0}
y={y}
/>
);
})}
</svg>
)}
{/* Segments */}
<div
className={cn(
"absolute inset-0 flex overflow-visible",
horiz ? "flex-row" : "flex-col"
)}
style={{ gap }}
>
{data.map((stage, i) => {
const normStart = norms[i] ?? 0;
const normEnd = norms[Math.min(i + 1, n - 1)] ?? 0;
const firstStop = stage.gradient?.[0];
const segColor = firstStop
? firstStop.color
: (stage.color ?? color);
return horiz ? (
<HSegment
color={segColor}
dimmed={hoveredIndex !== null && hoveredIndex !== i}
fullH={H}
gradientStops={stage.gradient}
hovered={hoveredIndex === i}
index={i}
key={stage.label}
layers={layers}
normEnd={normEnd}
normStart={normStart}
renderPattern={renderPattern}
segW={segW}
staggerDelay={staggerDelay}
straight={edges === "straight"}
/>
) : (
<VSegment
color={segColor}
dimmed={hoveredIndex !== null && hoveredIndex !== i}
fullW={W}
gradientStops={stage.gradient}
hovered={hoveredIndex === i}
index={i}
key={stage.label}
layers={layers}
normEnd={normEnd}
normStart={normStart}
renderPattern={renderPattern}
segH={segH}
staggerDelay={staggerDelay}
straight={edges === "straight"}
/>
);
})}
</div>
{/* Grid lines */}
{gridEnabled && showGridLines && (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full"
preserveAspectRatio="none"
role="presentation"
viewBox={`0 0 ${W} ${H}`}
>
{Array.from({ length: n - 1 }, (_, i) => {
const idx = i + 1;
if (horiz) {
const x = segW * idx + gap * i + gap / 2;
return (
<line
key={`grid-${idx}`}
stroke={gridLineColor}
strokeOpacity={gridLineOpacity}
strokeWidth={gridLineWidth}
x1={x}
x2={x}
y1={0}
y2={H}
/>
);
}
const y = segH * idx + gap * i + gap / 2;
return (
<line
key={`grid-${idx}`}
stroke={gridLineColor}
strokeOpacity={gridLineOpacity}
strokeWidth={gridLineWidth}
x1={0}
x2={W}
y1={y}
y2={y}
/>
);
})}
</svg>
)}
{/* Label overlays — hover triggers */}
{data.map((stage, i) => {
const pct = (stage.value / max) * 100;
const posStyle: CSSProperties = horiz
? { left: (segW + gap) * i, width: segW, top: 0, height: H }
: { top: (segH + gap) * i, height: segH, left: 0, width: W };
const isDimmed = hoveredIndex !== null && hoveredIndex !== i;
return (
<motion.div
animate={{ opacity: isDimmed ? 0.4 : 1 }}
className="absolute cursor-pointer"
key={`lbl-${stage.label}`}
onMouseEnter={() => setHoveredIndex(i)}
onMouseLeave={() => setHoveredIndex(null)}
style={{ ...posStyle, zIndex: 20 }}
transition={{ type: "spring", stiffness: 300, damping: 24 }}
>
<SegmentLabel
align={labelAlign}
formatPercentage={formatPercentage}
formatValue={formatValue}
index={i}
isHorizontal={horiz}
layout={labelLayout}
orientation={labelOrientation}
pct={pct}
showLabels={showLabels}
showPercentage={showPercentage}
showValues={showValues}
stage={stage}
staggerDelay={staggerDelay}
/>
</motion.div>
);
})}
</>
)}
</div>
);
}
FunnelChart.displayName = "FunnelChart";
export default FunnelChart;

View File

@@ -0,0 +1,30 @@
'use client'
import { FunnelChart } from './funnel-chart'
const funnelData = [
{ label: 'Homepage', value: 1240 },
{ label: 'Pricing', value: 438 },
{ label: 'Signup', value: 87 },
]
export function FunnelMockup() {
return (
<div className="relative w-full max-w-[600px] mx-auto">
<div className="rounded-xl border border-white/[0.08] bg-neutral-900/80 px-10 py-6 shadow-2xl">
<h3 className="text-sm font-medium text-white mb-4">Funnel Visualization</h3>
<FunnelChart
data={funnelData}
orientation="vertical"
color="var(--chart-1, #FD5E0F)"
layers={3}
className="mx-auto"
/>
<div className="flex items-center justify-between mt-4 pt-3 border-t border-neutral-800 text-[10px] text-neutral-500">
<span>Overall conversion: 7%</span>
<span>7-day window</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,119 @@
'use client'
function Toggle({ on = true }: { on?: boolean }) {
return (
<div
className={`relative w-9 h-5 rounded-full shrink-0 transition-colors ${
on ? 'bg-brand-orange' : 'bg-neutral-700'
}`}
>
<div
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
on ? 'translate-x-[18px]' : 'translate-x-0.5'
}`}
/>
</div>
)
}
export function ModularScriptMockup() {
return (
<div className="relative w-full max-w-[460px] mx-auto">
<div className="rounded-xl border border-white/[0.08] bg-neutral-900/90 shadow-2xl overflow-hidden px-6 py-5 space-y-5">
{/* Features heading */}
<h4 className="text-sm font-bold text-white">Features</h4>
{/* Feature toggles — 2 column grid */}
<div className="grid grid-cols-2 gap-2.5">
{[
{ name: 'Scroll depth', desc: 'Track 25 / 50 / 75 / 100%', on: true },
{ name: '404 detection', desc: 'Auto-detect error pages', on: true },
{ name: 'Outbound links', desc: 'Track external link clicks', on: true },
{ name: 'File downloads', desc: 'Track PDF, ZIP, and more', on: true },
].map((feature) => (
<div
key={feature.name}
className="rounded-lg border border-neutral-800 bg-neutral-800/40 px-3.5 py-3 flex items-center justify-between gap-2"
>
<div className="min-w-0">
<p className="text-xs font-semibold text-white leading-tight">{feature.name}</p>
<p className="text-[10px] text-neutral-500 leading-tight mt-0.5 truncate">{feature.desc}</p>
</div>
<Toggle on={feature.on} />
</div>
))}
</div>
{/* Frustration tracking — full width, disabled */}
<div className="rounded-lg border border-dashed border-neutral-700 bg-neutral-800/20 px-3.5 py-3 flex items-center justify-between gap-2">
<div>
<p className="text-xs font-semibold text-white leading-tight">Frustration tracking</p>
<p className="text-[10px] text-neutral-500 leading-tight mt-0.5">Rage clicks &amp; dead clicks &middot; Loads separate add-on script</p>
</div>
<Toggle on={false} />
</div>
{/* Visitor identity */}
<div>
<h4 className="text-sm font-bold text-white mb-1">Visitor identity</h4>
<p className="text-[10px] text-neutral-500 mb-3 leading-relaxed">
How returning visitors are recognized. Stricter settings increase privacy but may raise unique visitor counts.
</p>
<div className="flex items-center gap-3">
<div>
<p className="text-[10px] text-neutral-400 mb-1">Recognition</p>
<div className="flex items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-800/60 px-3 py-1.5 text-xs text-white">
Across all tabs
<svg className="w-3 h-3 text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</div>
</div>
<div>
<p className="text-[10px] text-neutral-400 mb-1">Reset after</p>
<div className="flex items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-800/60 px-3 py-1.5 text-xs text-white">
24 hours
<svg className="w-3 h-3 text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</div>
</div>
</div>
</div>
{/* Setup guide */}
<div>
<div className="flex items-center justify-between mb-2.5">
<h4 className="text-sm font-bold text-white">Setup guide</h4>
<span className="text-[10px] text-neutral-500">All integrations &rarr;</span>
</div>
<div className="flex flex-wrap gap-1.5">
{[
{ name: 'Next.js', icon: <svg viewBox="0 0 24 24" className="w-3.5 h-3.5 fill-current invert"><path d="M18.665 21.978C16.758 23.255 14.465 24 12 24 5.377 24 0 18.623 0 12S5.377 0 12 0s12 5.377 12 12c0 3.583-1.574 6.801-4.067 9.001L9.219 7.2H7.2v9.596h1.615V9.251l9.85 12.727Zm-3.332-8.533 1.6 2.061V7.2h-1.6v6.245Z" /></svg> },
{ name: 'React', icon: <svg viewBox="0 0 24 24" className="w-3.5 h-3.5" style={{ fill: '#61DAFB' }}><path d="M14.23 12.004a2.236 2.236 0 0 1-2.235 2.236 2.236 2.236 0 0 1-2.236-2.236 2.236 2.236 0 0 1 2.235-2.236 2.236 2.236 0 0 1 2.236 2.236zm2.648-10.69c-1.346 0-3.107.96-4.888 2.622-1.78-1.653-3.542-2.602-4.887-2.602-.31 0-.592.068-.846.182a1.993 1.993 0 0 0-.909.916C4.78 3.522 5.1 5.18 6.138 7.11 4.257 8.17 3 9.733 3 11.1c0 2.176 2.714 3.757 6.528 4.025-.172.583-.264 1.197-.264 1.833 0 3.235 2.33 5.862 5.204 5.862.876 0 1.699-.249 2.404-.68l-.766-1.289a3.268 3.268 0 0 1-1.638.44c-1.834 0-3.318-1.786-3.318-3.99 0-.42.05-.828.143-1.218 3.61-.179 6.373-1.664 6.573-3.842.685 1.56 1.057 3.024 1.057 4.168 0 .756-.165 1.344-.5 1.708l1.195 1.164c.694-.74 1.048-1.732 1.048-2.872 0-1.504-.536-3.346-1.487-5.294C20.836 8.794 22 7.482 22 5.862c0-2.393-2.272-4.548-5.122-4.548zM7.632 3.19c.395-.193.893-.29 1.478-.29.873 0 1.928.402 3.083 1.136-1.072 1.096-2.06 2.37-2.907 3.765a21.872 21.872 0 0 0-2.36.488c-.888-1.585-1.253-2.978-1.087-3.84a.91.91 0 0 1 .282-.517c.14-.132.308-.204.511-.204v-.538zm12.736 2.672c0 1.076-.897 2.142-2.347 3.007a22.076 22.076 0 0 0-2.377-3.3c1.225-.857 2.333-1.36 3.228-1.36.268 0 .502.047.706.135.288.124.504.337.605.612.076.207.185.525.185.906zM12 15.9c-2.14 0-4.028-.362-5.49-.943a9.09 9.09 0 0 1-.53-.235C4.949 14.068 4.2 13.27 4.2 12.3c0-1.14 1.268-2.498 3.3-3.39.287.81.626 1.647 1.015 2.494a21.27 21.27 0 0 0 1.534 2.682 20.258 20.258 0 0 0 3.902.057 21.27 21.27 0 0 0 1.535-2.682c.388-.847.727-1.684 1.015-2.494 2.032.892 3.3 2.25 3.3 3.39 0 .97-.75 1.769-1.78 2.422a9.09 9.09 0 0 1-.53.235c-1.462.581-3.35.943-5.49.943z" /></svg> },
{ name: 'Vue.js', icon: <svg viewBox="0 0 24 24" className="w-3.5 h-3.5" style={{ fill: '#4FC08D' }}><path d="M24,1.61H14.06L12,5.16,9.94,1.61H0L12,22.39ZM12,14.08,5.16,2.23H9.59L12,6.41l2.41-4.18h4.43Z" /></svg> },
{ name: 'Angular', icon: <svg viewBox="0 0 24 24" className="w-3.5 h-3.5 fill-current invert"><path d="M16.712 17.711H7.288l-1.204 2.916L12 24l5.916-3.373-1.204-2.916ZM14.692 0l7.832 16.855.814-12.856L14.692 0ZM9.308 0 .662 3.999l.814 12.856L9.308 0Zm-.405 13.93h6.198L12 6.396 8.903 13.93Z" /></svg> },
{ name: 'Svelte', icon: <svg viewBox="0 0 24 24" className="w-3.5 h-3.5" style={{ fill: '#FF3E00' }}><path d="M20.68 3.17a7.3 7.3 0 0 0-9.8-2.1L6.17 4.38A5.81 5.81 0 0 0 3.5 8.29a6 6 0 0 0 .62 3.77 5.7 5.7 0 0 0-.86 2.13 6.14 6.14 0 0 0 1.06 4.64 7.3 7.3 0 0 0 9.8 2.1l4.71-3.31a5.81 5.81 0 0 0 2.67-3.91 6 6 0 0 0-.62-3.77 5.7 5.7 0 0 0 .86-2.13 6.14 6.14 0 0 0-1.06-4.64z" /></svg> },
{ name: 'Nuxt', icon: <svg viewBox="0 0 24 24" className="w-3.5 h-3.5" style={{ fill: '#00DC82' }}><path d="M13.464 20.48H2.182a1.49 1.49 0 0 1-1.288-.746 1.49 1.49 0 0 1 0-1.49L7.537 6.584a1.49 1.49 0 0 1 2.576 0l1.635 2.835.002.004 3.005 5.21a.4.4 0 0 1-.345.597H8.862a.4.4 0 0 0-.346.598l2.158 3.749a.4.4 0 0 0 .693 0l5.78-10.028a.4.4 0 0 1 .693 0l6.287 10.903a.4.4 0 0 1-.347.598h-3.49" /></svg> },
{ name: 'Remix', icon: <svg viewBox="0 0 24 24" className="w-3.5 h-3.5 fill-current invert"><path d="M21.511 18.508c.216 2.773.216 4.073.216 5.492H15.31c0-.309.006-.592.011-.878.018-.892.036-1.821-.109-3.698-.19-2.747-1.374-3.358-3.55-3.358H1.574v-5h10.396c2.748 0 4.122-.835 4.122-3.049 0-1.946-1.374-3.125-4.122-3.125H1.573V0h11.541c6.221 0 9.313 2.938 9.313 7.632 0 3.511-2.176 5.8-5.114 6.182 2.48.497 3.93 1.909 4.198 4.694ZM1.573 24v-3.727h6.784c1.133 0 1.379.84 1.379 1.342V24Z" /></svg> },
{ name: 'Astro', icon: <svg viewBox="0 0 24 24" className="w-3.5 h-3.5" style={{ fill: '#BC52EE' }}><path d="M8.358 20.162c-1.186-1.07-1.532-3.316-1.038-4.944.71 1.12 1.74 1.846 2.9 2.13 1.79.438 3.638.423 5.39-.152.198-.065.384-.156.6-.246-.032.846-.241 1.62-.72 2.313-.717 1.04-1.722 1.627-2.945 1.795-1.414.194-2.697-.126-3.886-1.048-.107-.088-.2-.191-.3-.29v.442Z" /></svg> },
].map((fw) => (
<span
key={fw.name}
className="flex items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-800/60 px-2.5 py-1.5 text-[10px] text-neutral-300"
>
{fw.icon}
{fw.name}
</span>
))}
</div>
</div>
{/* Verified status */}
<div className="flex items-center gap-2 pt-1">
<span className="flex items-center gap-1.5 rounded-lg border border-green-500/20 bg-green-500/5 px-2.5 py-1 text-[10px] text-green-400 font-medium">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" /></svg>
Verified
</span>
<span className="text-[10px] text-neutral-500">Your site is sending data correctly.</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,544 @@
'use client'
import { useState, useEffect, useCallback, useMemo, type CSSProperties } from 'react'
import { createMap } from 'svg-dotted-map'
import {
Files,
ArrowSquareOut,
MapPin,
Monitor,
Clock,
Globe,
GoogleLogo,
XLogo,
GithubLogo,
YoutubeLogo,
RedditLogo,
Link,
} from '@phosphor-icons/react'
// ─── Dotted Map Setup (module-level, computed once) ──────────────────────────
const MAP_WIDTH = 150
const MAP_HEIGHT = 68
const DOT_RADIUS = 0.25
const { points: MAP_POINTS, addMarkers } = createMap({
width: MAP_WIDTH,
height: MAP_HEIGHT,
mapSamples: 8000,
})
const _stagger = (() => {
const sorted = [...MAP_POINTS].sort((a, b) => a.y - b.y || a.x - b.x)
const rowMap = new Map<number, number>()
let step = 0
let prevY = Number.NaN
let prevXInRow = Number.NaN
for (const p of sorted) {
if (p.y !== prevY) {
prevY = p.y
prevXInRow = Number.NaN
if (!rowMap.has(p.y)) rowMap.set(p.y, rowMap.size)
}
if (!Number.isNaN(prevXInRow)) {
const delta = p.x - prevXInRow
if (delta > 0) step = step === 0 ? delta : Math.min(step, delta)
}
prevXInRow = p.x
}
return { xStep: step || 1, yToRowIndex: rowMap }
})()
const BASE_DOTS_PATH = (() => {
const r = DOT_RADIUS
const d = r * 2
const parts: string[] = []
for (const point of MAP_POINTS) {
const rowIndex = _stagger.yToRowIndex.get(point.y) ?? 0
const offsetX = rowIndex % 2 === 1 ? _stagger.xStep / 2 : 0
const cx = point.x + offsetX
const cy = point.y
parts.push(`M${cx - r},${cy}a${r},${r} 0 1,0 ${d},0a${r},${r} 0 1,0 ${-d},0`)
}
return parts.join('')
})()
// Country centroids for marker placement (subset)
const COUNTRY_CENTROIDS: Record<string, { lat: number; lng: number }> = {
CH: { lat: 46.8, lng: 8.2 },
DE: { lat: 51.2, lng: 10.4 },
US: { lat: 37.1, lng: -95.7 },
GB: { lat: 55.4, lng: -3.4 },
FR: { lat: 46.2, lng: 2.2 },
IN: { lat: 20.6, lng: 78.9 },
JP: { lat: 36.2, lng: 138.3 },
AU: { lat: -25.3, lng: 133.8 },
BR: { lat: -14.2, lng: -51.9 },
CA: { lat: 56.1, lng: -106.3 },
}
// ─── Bar Row (shared by Pages, Referrers, Technology) ────────────────────────
function BarRow({
label,
value,
maxValue,
icon,
}: {
label: string
value: number
maxValue: number
icon?: React.ReactNode
}) {
const pct = (value / maxValue) * 100
return (
<div className="flex items-center gap-3">
<div className="relative flex-1 h-[30px] flex items-center">
<div
className="absolute inset-y-0 left-0 rounded-md bg-brand-orange/25"
style={{ width: `${pct}%` }}
/>
<div className="relative z-10 flex items-center gap-2 pl-2.5">
{icon && <span className="w-4 h-4 flex items-center justify-center shrink-0">{icon}</span>}
<span className="text-xs text-white font-medium truncate">{label}</span>
</div>
</div>
<span className="text-xs text-neutral-400 tabular-nums w-8 text-right shrink-0">{value}</span>
</div>
)
}
// ─── Card 1: Pages ───────────────────────────────────────────────────────────
function PagesCard() {
const data = [
{ label: '/', value: 142 },
{ label: '/products/drop', value: 68 },
{ label: '/pricing', value: 31 },
{ label: '/blog', value: 24 },
{ label: '/about', value: 12 },
{ label: '/products/pulse', value: 9 },
]
const max = data[0].value
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Files className="w-4 h-4 text-neutral-400" weight="bold" />
<h4 className="text-sm font-bold text-white">Pages</h4>
</div>
<div className="flex items-center gap-3 text-[10px]">
<span className="text-white border-b border-brand-orange pb-0.5">Top Pages</span>
<span className="text-neutral-500">Entry</span>
<span className="text-neutral-500">Exit</span>
</div>
</div>
<div className="space-y-1.5">
{data.map((d) => (
<BarRow key={d.label} label={d.label} value={d.value} maxValue={max} />
))}
</div>
</div>
)
}
// ─── Card 2: Referrers ───────────────────────────────────────────────────────
function getReferrerIcon(name: string, favicon?: string) {
// Use Google Favicon API for sites with domains (like real Pulse)
if (favicon) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img src={favicon} alt="" width={16} height={16} className="w-4 h-4 rounded object-contain" />
)
}
const lower = name.toLowerCase()
if (lower === 'direct') return <Globe className="w-4 h-4 text-neutral-400" />
if (lower.includes('google')) return <GoogleLogo className="w-4 h-4 text-blue-500" />
if (lower.includes('twitter') || lower.includes('x')) return <XLogo className="w-4 h-4 text-neutral-200" />
if (lower.includes('github')) return <GithubLogo className="w-4 h-4 text-neutral-200" />
if (lower.includes('youtube')) return <YoutubeLogo className="w-4 h-4 text-red-500" />
if (lower.includes('reddit')) return <RedditLogo className="w-4 h-4 text-orange-500" />
if (lower.includes('hacker') || lower.includes('hn')) return <Link className="w-4 h-4 text-orange-400" />
return <Globe className="w-4 h-4 text-neutral-400" />
}
const FAVICON_URL = 'https://www.google.com/s2/favicons'
function ReferrersCard() {
const data = [
{ label: 'Direct', value: 186 },
{ label: 'Google', value: 94, domain: 'google.com' },
{ label: 'Twitter / X', value: 47 },
{ label: 'GitHub', value: 32, domain: 'github.com' },
{ label: 'Hacker News', value: 18, domain: 'news.ycombinator.com' },
{ label: 'Reddit', value: 11, domain: 'reddit.com' },
]
const max = data[0].value
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<ArrowSquareOut className="w-4 h-4 text-neutral-400" weight="bold" />
<h4 className="text-sm font-bold text-white">Referrers</h4>
</div>
<div className="space-y-1.5">
{data.map((d) => (
<BarRow
key={d.label}
label={d.label}
value={d.value}
maxValue={max}
icon={getReferrerIcon(
d.label,
'domain' in d ? `${FAVICON_URL}?domain=${d.domain}&sz=32` : undefined
)}
/>
))}
</div>
</div>
)
}
// ─── Card 3: Locations (Real Dotted Map) ─────────────────────────────────────
function LocationsCard() {
const mockData = [
{ country: 'CH', pageviews: 320 },
{ country: 'US', pageviews: 186 },
{ country: 'DE', pageviews: 142 },
{ country: 'GB', pageviews: 78 },
{ country: 'FR', pageviews: 54 },
{ country: 'IN', pageviews: 38 },
{ country: 'JP', pageviews: 22 },
{ country: 'AU', pageviews: 16 },
{ country: 'BR', pageviews: 12 },
{ country: 'CA', pageviews: 28 },
]
const markerData = useMemo(() => {
const max = Math.max(...mockData.map((d) => d.pageviews))
return mockData
.filter((d) => COUNTRY_CENTROIDS[d.country])
.map((d) => ({
lat: COUNTRY_CENTROIDS[d.country].lat,
lng: COUNTRY_CENTROIDS[d.country].lng,
size: 0.4 + (d.pageviews / max) * 0.8,
}))
}, [])
const processedMarkers = useMemo(() => addMarkers(markerData), [markerData])
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-neutral-400" weight="bold" />
<h4 className="text-sm font-bold text-white">Locations</h4>
</div>
<div className="flex items-center gap-3 text-[10px]">
<span className="text-white border-b border-brand-orange pb-0.5">Map</span>
<span className="text-neutral-500">Countries</span>
<span className="text-neutral-500">Regions</span>
<span className="text-neutral-500">Cities</span>
</div>
</div>
<div className="relative w-full aspect-[2.2/1] flex items-center justify-center">
<svg
viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`}
className="text-neutral-500 w-full h-full"
>
<defs>
<filter id="mockup-marker-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="0.8" result="blur" />
<feColorMatrix
in="blur"
type="matrix"
values="1 0 0 0 0 0 0.4 0 0 0 0 0 0 0 0 0 0 0 0.6 0"
/>
<feMerge>
<feMergeNode />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<path d={BASE_DOTS_PATH} fill="currentColor" />
{processedMarkers.map((marker, index) => {
const rowIndex = _stagger.yToRowIndex.get(marker.y) ?? 0
const offsetX = rowIndex % 2 === 1 ? _stagger.xStep / 2 : 0
const cx = marker.x + offsetX
const cy = marker.y
return (
<circle
key={`marker-${index}`}
cx={cx}
cy={cy}
r={marker.size ?? DOT_RADIUS}
fill="#FD5E0F"
filter="url(#mockup-marker-glow)"
/>
)
})}
</svg>
</div>
</div>
)
}
// ─── Card 4: Technology ──────────────────────────────────────────────────────
const BROWSER_ICONS: Record<string, string> = {
Chrome: '/icons/browsers/chrome.svg',
Safari: '/icons/browsers/safari.svg',
Firefox: '/icons/browsers/firefox.svg',
Edge: '/icons/browsers/edge.svg',
Arc: '/icons/browsers/arc.png',
Opera: '/icons/browsers/opera.svg',
}
function TechnologyCard() {
const data = [
{ label: 'Chrome', value: 412 },
{ label: 'Safari', value: 189 },
{ label: 'Firefox', value: 76 },
{ label: 'Edge', value: 34 },
{ label: 'Arc', value: 18 },
{ label: 'Opera', value: 7 },
]
const max = data[0].value
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Monitor className="w-4 h-4 text-neutral-400" weight="bold" />
<h4 className="text-sm font-bold text-white">Technology</h4>
</div>
<div className="flex items-center gap-3 text-[10px]">
<span className="text-white border-b border-brand-orange pb-0.5">Browsers</span>
<span className="text-neutral-500">OS</span>
<span className="text-neutral-500">Devices</span>
<span className="text-neutral-500">Screens</span>
</div>
</div>
<div className="space-y-1.5">
{data.map((d) => (
<BarRow
key={d.label}
label={d.label}
value={d.value}
maxValue={max}
icon={
BROWSER_ICONS[d.label] ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={BROWSER_ICONS[d.label]} alt={d.label} width={16} height={16} className="w-4 h-4" />
) : undefined
}
/>
))}
</div>
</div>
)
}
// ─── Card 5: Peak Hours (Exact Pulse Heatmap) ────────────────────────────────
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const BUCKETS = 12
const BUCKET_LABELS: Record<number, string> = { 0: '00:00', 3: '06:00', 6: '12:00', 9: '18:00' }
const HIGHLIGHT_COLORS = [
'transparent',
'rgba(253,94,15,0.15)',
'rgba(253,94,15,0.35)',
'rgba(253,94,15,0.60)',
'rgba(253,94,15,0.82)',
'#FD5E0F',
]
// Pre-computed mock heatmap grid[day][bucket] with raw values
const MOCK_GRID = [
[0, 0, 12, 28, 32, 45, 52, 48, 35, 24, 8, 0], // Mon
[0, 0, 8, 22, 38, 50, 58, 46, 40, 28, 12, 4], // Tue
[0, 0, 6, 18, 26, 42, 48, 56, 38, 22, 10, 0], // Wed
[0, 4, 10, 24, 42, 62, 86, 68, 44, 26, 12, 6], // Thu
[0, 6, 16, 34, 44, 58, 64, 48, 42, 28, 14, 0], // Fri
[4, 6, 8, 18, 22, 24, 26, 22, 32, 36, 20, 8], // Sat
[6, 4, 6, 10, 16, 20, 22, 14, 18, 24, 16, 8], // Sun
]
function getHighlightColor(value: number, max: number): string {
if (value === 0) return HIGHLIGHT_COLORS[0]
if (value === max) return HIGHLIGHT_COLORS[5]
const ratio = value / max
if (ratio <= 0.25) return HIGHLIGHT_COLORS[1]
if (ratio <= 0.50) return HIGHLIGHT_COLORS[2]
if (ratio <= 0.75) return HIGHLIGHT_COLORS[3]
return HIGHLIGHT_COLORS[4]
}
function PeakHoursCard() {
const max = Math.max(...MOCK_GRID.flat())
// Find best time
let bestDay = 0
let bestBucket = 0
let bestVal = 0
for (let d = 0; d < 7; d++) {
for (let b = 0; b < BUCKETS; b++) {
if (MOCK_GRID[d][b] > bestVal) {
bestVal = MOCK_GRID[d][b]
bestDay = d
bestBucket = b
}
}
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-neutral-400" weight="bold" />
<h4 className="text-sm font-bold text-white">Peak Hours</h4>
</div>
</div>
<p className="text-[10px] text-neutral-500 mb-2">When your visitors are most active</p>
<div className="flex flex-col gap-[5px]">
{MOCK_GRID.map((buckets, dayIdx) => (
<div key={dayIdx} className="flex items-center gap-1.5">
<span className="text-[11px] text-neutral-500 w-7 shrink-0 text-right leading-none">
{DAYS[dayIdx]}
</span>
<div
className="flex-1"
style={{
display: 'grid',
gridTemplateColumns: `repeat(${BUCKETS}, 1fr)`,
gap: '5px',
}}
>
{buckets.map((value, bucket) => {
const isBestCell = bestDay === dayIdx && bestBucket === bucket
return (
<div
key={bucket}
className={`aspect-square w-full rounded-[4px] border border-neutral-800 ${
isBestCell ? 'ring-1 ring-brand-orange/40' : ''
}`}
style={{
backgroundColor: getHighlightColor(value, max),
} as CSSProperties}
/>
)
})}
</div>
</div>
))}
</div>
{/* Hour axis labels */}
<div className="flex items-center gap-1.5">
<span className="w-7 shrink-0" />
<div className="flex-1 relative h-3">
{Object.entries(BUCKET_LABELS).map(([b, label]) => (
<span
key={b}
className="absolute text-[10px] text-neutral-600 -translate-x-1/2"
style={{ left: `${(Number(b) / BUCKETS) * 100}%` }}
>
{label}
</span>
))}
<span
className="absolute text-[10px] text-neutral-600 -translate-x-full"
style={{ left: '100%' }}
>
24:00
</span>
</div>
</div>
{/* Intensity legend */}
<div className="flex items-center justify-end gap-1.5 mt-1">
<span className="text-[10px] text-neutral-500">Less</span>
{HIGHLIGHT_COLORS.map((color, i) => (
<div
key={i}
className="w-[10px] h-[10px] rounded-[2px] border border-neutral-800"
style={{ backgroundColor: color }}
/>
))}
<span className="text-[10px] text-neutral-500">More</span>
</div>
<p className="text-[10px] text-neutral-400 text-center mt-1">
Your busiest time is{' '}
<span className="text-brand-orange font-medium">
{['Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays', 'Sundays'][bestDay]} at {String(bestBucket * 2).padStart(2, '0')}:00
</span>
</p>
</div>
)
}
// ─── Carousel ────────────────────────────────────────────────────────────────
const cards = [
{ id: 'pages', Component: PagesCard, title: 'Top Pages' },
{ id: 'referrers', Component: ReferrersCard, title: 'Referrers' },
{ id: 'locations', Component: LocationsCard, title: 'Locations' },
{ id: 'technology', Component: TechnologyCard, title: 'Technology' },
{ id: 'peak-hours', Component: PeakHoursCard, title: 'Peak Hours' },
]
export function PulseFeaturesCarousel() {
const [active, setActive] = useState(0)
const [paused, setPaused] = useState(false)
const next = useCallback(() => {
setActive((prev) => (prev + 1) % cards.length)
}, [])
useEffect(() => {
if (paused) return
const interval = setInterval(next, 4000)
return () => clearInterval(interval)
}, [paused, next])
const ActiveComponent = cards[active].Component
return (
<div
className="relative w-full max-w-[520px] mx-auto"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<div className="rounded-xl border border-white/[0.08] bg-neutral-900/80 px-6 py-5 shadow-2xl">
<div className="min-h-[280px]">
<ActiveComponent />
</div>
</div>
{/* Dot indicators */}
<div className="flex items-center justify-center gap-2.5 mt-4">
{cards.map((card, i) => (
<button
key={card.id}
onClick={() => setActive(i)}
className={`h-2 rounded-full transition-all duration-300 ${
i === active
? 'w-7 bg-brand-orange'
: 'w-2 bg-neutral-600 hover:bg-neutral-500'
}`}
aria-label={`Show ${card.title}`}
/>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,197 @@
'use client'
export function PulseMockup() {
return (
<div className="relative w-full max-w-[440px] mx-auto">
<div className="rounded-xl border border-white/[0.08] bg-neutral-900/80 px-5 py-4 shadow-2xl space-y-3">
{/* Header row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div>
<h3 className="text-sm font-bold text-white">Ciphera</h3>
<p className="text-[9px] text-neutral-500">ciphera.net</p>
</div>
<div className="flex items-center gap-1.5 bg-green-500/10 border border-green-500/20 rounded-full px-2.5 py-0.5">
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
<span className="text-[9px] text-green-400 font-medium">4 current visitors</span>
</div>
</div>
<div className="flex items-center gap-2">
<button className="flex items-center gap-1.5 rounded-lg bg-brand-orange px-2.5 py-1 text-[10px] font-medium text-white cursor-default">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Export
</button>
<div className="flex items-center gap-1 rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 py-1 text-[10px] text-neutral-300 cursor-default">
Today
<svg className="w-2.5 h-2.5 text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</div>
{/* Filter button */}
<div>
<button className="flex items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-800/50 px-2.5 py-1 text-[10px] text-neutral-400 cursor-default">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
Filter
</button>
</div>
{/* Stats row */}
<div className="grid grid-cols-4 gap-2">
{/* Unique Visitors — selected/highlighted */}
<div className="rounded-lg border border-neutral-700 bg-neutral-800/60 p-2.5 relative">
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-brand-orange rounded-b-lg" />
<p className="text-[7px] text-brand-orange font-semibold uppercase tracking-wider">Unique Visitors</p>
<div className="flex items-baseline gap-1.5 mt-1">
<p className="text-base font-bold text-white leading-none">247</p>
<span className="text-[8px] text-red-500 font-medium flex items-center gap-0.5">
<svg className="w-2 h-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M19 14l-7 7m0 0l-7-7" />
</svg>
12%
</span>
</div>
<p className="text-[8px] text-neutral-500 mt-0.5">vs yesterday</p>
</div>
{/* Total Pageviews */}
<div className="rounded-lg border border-neutral-800 bg-neutral-900 p-2.5">
<p className="text-[7px] text-neutral-500 font-semibold uppercase tracking-wider">Total Pageviews</p>
<div className="flex items-baseline gap-1.5 mt-1">
<p className="text-base font-bold text-white leading-none">512</p>
<span className="text-[8px] text-red-500 font-medium flex items-center gap-0.5">
<svg className="w-2 h-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M19 14l-7 7m0 0l-7-7" />
</svg>
23%
</span>
</div>
<p className="text-[8px] text-neutral-500 mt-0.5">vs yesterday</p>
</div>
{/* Bounce Rate */}
<div className="rounded-lg border border-neutral-800 bg-neutral-900 p-2.5">
<p className="text-[7px] text-neutral-500 font-semibold uppercase tracking-wider">Bounce Rate</p>
<div className="flex items-baseline gap-1.5 mt-1">
<p className="text-base font-bold text-white leading-none">68%</p>
<span className="text-[8px] text-green-500 font-medium flex items-center gap-0.5">
<svg className="w-2 h-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 10l7-7m0 0l7 7" />
</svg>
8%
</span>
</div>
<p className="text-[8px] text-neutral-500 mt-0.5">vs yesterday</p>
</div>
{/* Visit Duration */}
<div className="rounded-lg border border-neutral-800 bg-neutral-900 p-2.5">
<p className="text-[7px] text-neutral-500 font-semibold uppercase tracking-wider">Visit Duration</p>
<div className="flex items-baseline gap-1.5 mt-1">
<p className="text-base font-bold text-white leading-none">3m 18s</p>
<span className="text-[8px] text-red-500 font-medium flex items-center gap-0.5">
<svg className="w-2 h-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M19 14l-7 7m0 0l-7-7" />
</svg>
15%
</span>
</div>
<p className="text-[8px] text-neutral-500 mt-0.5">vs yesterday</p>
</div>
</div>
{/* Chart area */}
<div className="rounded-lg border border-neutral-800 bg-neutral-900 p-3">
{/* Chart header */}
<div className="flex items-center justify-between mb-3">
<span className="text-[10px] text-neutral-300 font-medium">Unique Visitors</span>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 rounded-md border border-neutral-700 bg-neutral-800 px-2 py-0.5 text-[9px] text-neutral-300 cursor-default">
1 hour
<svg className="w-2 h-2 text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded border border-neutral-700 bg-transparent" />
<span className="text-[9px] text-neutral-500">Compare</span>
<svg className="w-3 h-3 text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<svg className="w-3 h-3 text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
</div>
</div>
{/* SVG Chart — step-style like the real dashboard */}
<div className="relative h-[120px] w-full">
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-4 flex flex-col justify-between text-[7px] text-neutral-600 w-5">
<span>8</span>
<span>6</span>
<span>4</span>
<span>2</span>
<span>0</span>
</div>
{/* Chart */}
<svg className="absolute left-6 right-0 top-0 bottom-4" viewBox="0 0 400 100" preserveAspectRatio="none">
{/* Grid lines */}
<line x1="0" y1="0" x2="400" y2="0" stroke="rgba(255,255,255,0.04)" />
<line x1="0" y1="25" x2="400" y2="25" stroke="rgba(255,255,255,0.04)" />
<line x1="0" y1="50" x2="400" y2="50" stroke="rgba(255,255,255,0.04)" />
<line x1="0" y1="75" x2="400" y2="75" stroke="rgba(255,255,255,0.04)" />
<line x1="0" y1="100" x2="400" y2="100" stroke="rgba(255,255,255,0.04)" />
{/* Area fill — step-style chart */}
<path
d="M0,62 L45,62 L45,62 L90,62 L90,100 L135,100 L135,100 L160,100 L160,62 L180,62 L180,50 L225,50 L225,25 L270,25 L270,25 L290,25 L290,50 L310,50 L310,62 L340,62 L340,62 L370,62 L370,55 L400,55 L400,100 L0,100 Z"
fill="url(#pulseMockupGradient)"
/>
{/* Line — step-style */}
<path
d="M0,62 L45,62 L45,62 L90,62 L90,100 L135,100 L135,100 L160,100 L160,62 L180,62 L180,50 L225,50 L225,25 L270,25 L270,25 L290,25 L290,50 L310,50 L310,62 L340,62 L340,62 L370,62 L370,55 L400,55"
fill="none"
stroke="#FD5E0F"
strokeWidth="2"
/>
<defs>
<linearGradient id="pulseMockupGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#FD5E0F" stopOpacity="0.25" />
<stop offset="100%" stopColor="#FD5E0F" stopOpacity="0.02" />
</linearGradient>
</defs>
</svg>
</div>
{/* X-axis labels */}
<div className="flex justify-between pl-6 text-[7px] text-neutral-600 mt-0.5">
<span>01:00</span>
<span>04:00</span>
<span>07:00</span>
<span>10:00</span>
<span>13:00</span>
<span>16:00</span>
<span>19:00</span>
</div>
</div>
{/* Live indicator */}
<div className="flex items-center justify-end gap-1.5">
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
<span className="text-[9px] text-neutral-500">Live · 27 seconds ago</span>
</div>
</div>
</div>
)
}

View File

@@ -37,6 +37,7 @@ type FeatureKey = (typeof FEATURES)[number]['key'] | 'frustration'
export interface ScriptSetupBlockSite {
domain: string
name?: string
script_features?: Record<string, unknown>
}
interface ScriptSetupBlockProps {
@@ -44,27 +45,39 @@ interface ScriptSetupBlockProps {
site: ScriptSetupBlockSite
/** Called when user copies the script (e.g. for analytics). */
onScriptCopy?: () => void
/** Called when features change so the parent can save to backend. */
onFeaturesChange?: (features: Record<string, unknown>) => void
/** Show framework picker. Default true. */
showFrameworkPicker?: boolean
/** Optional class for the root wrapper. */
className?: string
}
const DEFAULT_FEATURES: Record<FeatureKey, boolean> = {
scroll: true,
'404': true,
outbound: true,
downloads: true,
frustration: false,
}
export default function ScriptSetupBlock({
site,
onScriptCopy,
onFeaturesChange,
showFrameworkPicker = true,
className = '',
}: ScriptSetupBlockProps) {
const sf = site.script_features || {}
const [features, setFeatures] = useState<Record<FeatureKey, boolean>>({
scroll: true,
'404': true,
outbound: true,
downloads: true,
frustration: false,
scroll: sf.scroll != null ? Boolean(sf.scroll) : DEFAULT_FEATURES.scroll,
'404': sf['404'] != null ? Boolean(sf['404']) : DEFAULT_FEATURES['404'],
outbound: sf.outbound != null ? Boolean(sf.outbound) : DEFAULT_FEATURES.outbound,
downloads: sf.downloads != null ? Boolean(sf.downloads) : DEFAULT_FEATURES.downloads,
frustration: sf.frustration != null ? Boolean(sf.frustration) : DEFAULT_FEATURES.frustration,
})
const [storage, setStorage] = useState('local')
const [ttl, setTtl] = useState('24')
const [storage, setStorage] = useState(typeof sf.storage === 'string' ? sf.storage : 'local')
const [ttl, setTtl] = useState(typeof sf.ttl === 'string' ? sf.ttl : '24')
const [framework, setFramework] = useState('')
const [copied, setCopied] = useState(false)
@@ -97,7 +110,11 @@ export default function ScriptSetupBlock({
}, [scriptSnippet, onScriptCopy])
const toggleFeature = (key: FeatureKey) => {
setFeatures((prev) => ({ ...prev, [key]: !prev[key] }))
setFeatures((prev) => {
const next = { ...prev, [key]: !prev[key] }
onFeaturesChange?.({ ...next, storage, ttl })
return next
})
}
const selectedIntegration = framework ? getIntegration(framework) : null
@@ -201,7 +218,7 @@ export default function ScriptSetupBlock({
<Select
variant="input"
value={storage}
onChange={setStorage}
onChange={(v: string) => { setStorage(v); onFeaturesChange?.({ ...features, storage: v, ttl }) }}
options={STORAGE_OPTIONS}
/>
</div>
@@ -213,7 +230,7 @@ export default function ScriptSetupBlock({
<Select
variant="input"
value={ttl}
onChange={setTtl}
onChange={(v: string) => { setTtl(v); onFeaturesChange?.({ ...features, storage, ttl: v }) }}
options={TTL_OPTIONS}
/>
</div>

2293
components/ui/area-chart.tsx Normal file

File diff suppressed because it is too large Load Diff

911
components/ui/bar-chart.tsx Normal file
View File

@@ -0,0 +1,911 @@
"use client";
import { localPoint } from "@visx/event";
import { LinearGradient as VisxLinearGradient } from "@visx/gradient";
import { GridColumns, GridRows } from "@visx/grid";
import { ParentSize } from "@visx/responsive";
import { scaleBand, scaleLinear } from "@visx/scale";
import {
AnimatePresence,
motion,
useSpring,
} from "motion/react";
import {
Children,
createContext,
isValidElement,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
type Dispatch,
type ReactElement,
type ReactNode,
type RefObject,
type SetStateAction,
} from "react";
import useMeasure from "react-use-measure";
import { createPortal } from "react-dom";
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
// ─── Utils ───────────────────────────────────────────────────────────────────
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// ─── CSS Vars ────────────────────────────────────────────────────────────────
export const chartCssVars = {
background: "var(--chart-background)",
foreground: "var(--chart-foreground)",
foregroundMuted: "var(--chart-foreground-muted)",
label: "var(--chart-label)",
linePrimary: "var(--chart-line-primary)",
lineSecondary: "var(--chart-line-secondary)",
crosshair: "var(--chart-crosshair)",
grid: "var(--chart-grid)",
};
// ─── Types ───────────────────────────────────────────────────────────────────
export interface Margin {
top: number;
right: number;
bottom: number;
left: number;
}
export interface TooltipData {
point: Record<string, unknown>;
index: number;
x: number;
yPositions: Record<string, number>;
xPositions?: Record<string, number>;
}
export interface TooltipRow {
color: string;
label: string;
value: string | number;
}
export interface LineConfig {
dataKey: string;
stroke: string;
strokeWidth: number;
}
// ─── Bar Chart Context ───────────────────────────────────────────────────────
type ScaleLinearType<Output> = ReturnType<typeof scaleLinear<Output>>;
type ScaleBandType<Domain extends { toString(): string }> = ReturnType<
typeof scaleBand<Domain>
>;
export interface BarChartContextValue {
data: Record<string, unknown>[];
xScale: ScaleBandType<string>;
yScale: ScaleLinearType<number>;
width: number;
height: number;
innerWidth: number;
innerHeight: number;
margin: Margin;
bandWidth: number;
tooltipData: TooltipData | null;
setTooltipData: Dispatch<SetStateAction<TooltipData | null>>;
containerRef: RefObject<HTMLDivElement | null>;
bars: BarConfig[];
isLoaded: boolean;
animationDuration: number;
xDataKey: string;
hoveredBarIndex: number | null;
setHoveredBarIndex: (index: number | null) => void;
orientation: "vertical" | "horizontal";
stacked: boolean;
stackGap: number;
stackOffsets: Map<number, Map<string, number>>;
barGap: number;
barWidth?: number;
}
interface BarConfig {
dataKey: string;
fill: string;
stroke?: string;
}
const BarChartContext = createContext<BarChartContextValue | null>(null);
function BarChartProvider({
children,
value,
}: {
children: ReactNode;
value: BarChartContextValue;
}) {
return (
<BarChartContext.Provider value={value}>
{children}
</BarChartContext.Provider>
);
}
export function useChart(): BarChartContextValue {
const context = useContext(BarChartContext);
if (!context) {
throw new Error(
"useChart must be used within a BarChartProvider. " +
"Make sure your component is wrapped in <BarChart>."
);
}
return context;
}
// ─── Tooltip Components ──────────────────────────────────────────────────────
interface TooltipDotProps {
x: number;
y: number;
visible: boolean;
color: string;
size?: number;
strokeColor?: string;
strokeWidth?: number;
}
function TooltipDot({
x,
y,
visible,
color,
size = 5,
strokeColor = chartCssVars.background,
strokeWidth = 2,
}: TooltipDotProps) {
const springConfig = { stiffness: 300, damping: 30 };
const animatedX = useSpring(x, springConfig);
const animatedY = useSpring(y, springConfig);
useEffect(() => {
animatedX.set(x);
animatedY.set(y);
}, [x, y, animatedX, animatedY]);
if (!visible) return null;
return (
<motion.circle
cx={animatedX}
cy={animatedY}
fill={color}
r={size}
stroke={strokeColor}
strokeWidth={strokeWidth}
/>
);
}
TooltipDot.displayName = "TooltipDot";
interface TooltipIndicatorProps {
x: number;
height: number;
visible: boolean;
width?: number;
colorEdge?: string;
colorMid?: string;
fadeEdges?: boolean;
gradientId?: string;
}
function TooltipIndicator({
x,
height,
visible,
width = 1,
colorEdge = chartCssVars.crosshair,
colorMid = chartCssVars.crosshair,
fadeEdges = true,
gradientId = "bar-tooltip-indicator-gradient",
}: TooltipIndicatorProps) {
const springConfig = { stiffness: 300, damping: 30 };
const animatedX = useSpring(x - width / 2, springConfig);
useEffect(() => {
animatedX.set(x - width / 2);
}, [x, animatedX, width]);
if (!visible) return null;
const edgeOpacity = fadeEdges ? 0 : 1;
return (
<g>
<defs>
<linearGradient id={gradientId} x1="0%" x2="0%" y1="0%" y2="100%">
<stop offset="0%" style={{ stopColor: colorEdge, stopOpacity: edgeOpacity }} />
<stop offset="10%" style={{ stopColor: colorEdge, stopOpacity: 1 }} />
<stop offset="50%" style={{ stopColor: colorMid, stopOpacity: 1 }} />
<stop offset="90%" style={{ stopColor: colorEdge, stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: colorEdge, stopOpacity: edgeOpacity }} />
</linearGradient>
</defs>
<motion.rect fill={`url(#${gradientId})`} height={height} width={width} x={animatedX} y={0} />
</g>
);
}
TooltipIndicator.displayName = "TooltipIndicator";
interface TooltipContentProps {
title?: string;
rows: TooltipRow[];
children?: ReactNode;
}
function TooltipContent({ title, rows, children }: TooltipContentProps) {
const [measureRef, bounds] = useMeasure({ debounce: 0, scroll: false });
const [committedHeight, setCommittedHeight] = useState<number | null>(null);
const committedChildrenStateRef = useRef<boolean | null>(null);
const frameRef = useRef<number | null>(null);
const hasChildren = !!children;
const markerKey = hasChildren ? "has-marker" : "no-marker";
const isWaitingForSettlement = committedChildrenStateRef.current !== null && committedChildrenStateRef.current !== hasChildren;
useEffect(() => {
if (bounds.height <= 0) return;
if (frameRef.current) { cancelAnimationFrame(frameRef.current); frameRef.current = null; }
if (isWaitingForSettlement) {
frameRef.current = requestAnimationFrame(() => {
frameRef.current = requestAnimationFrame(() => {
setCommittedHeight(bounds.height);
committedChildrenStateRef.current = hasChildren;
});
});
} else {
setCommittedHeight(bounds.height);
committedChildrenStateRef.current = hasChildren;
}
return () => { if (frameRef.current) cancelAnimationFrame(frameRef.current); };
}, [bounds.height, hasChildren, isWaitingForSettlement]);
const shouldAnimate = committedHeight !== null;
return (
<motion.div
animate={committedHeight !== null ? { height: committedHeight } : undefined}
className="overflow-hidden"
initial={false}
transition={shouldAnimate ? { type: "spring", stiffness: 500, damping: 35, mass: 0.8 } : { duration: 0 }}
>
<div className="px-3 py-2.5" ref={measureRef}>
{title && <div className="mb-2 font-medium text-neutral-400 text-xs">{title}</div>}
<div className="space-y-1.5">
{rows.map((row) => (
<div className="flex items-center justify-between gap-4" key={`${row.label}-${row.color}`}>
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: row.color }} />
<span className="text-neutral-400 text-sm">{row.label}</span>
</div>
<span className="font-medium text-white text-sm tabular-nums">
{typeof row.value === "number" ? row.value.toLocaleString() : row.value}
</span>
</div>
))}
</div>
<AnimatePresence mode="wait">
{children && (
<motion.div animate={{ opacity: 1, filter: "blur(0px)" }} className="mt-2" exit={{ opacity: 0, filter: "blur(4px)" }} initial={{ opacity: 0, filter: "blur(4px)" }} key={markerKey} transition={{ duration: 0.2, ease: "easeOut" }}>
{children}
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
);
}
TooltipContent.displayName = "TooltipContent";
interface TooltipBoxProps {
x: number;
y: number;
visible: boolean;
containerRef: RefObject<HTMLDivElement | null>;
containerWidth: number;
containerHeight: number;
offset?: number;
className?: string;
children: ReactNode;
top?: number | ReturnType<typeof useSpring>;
}
function TooltipBox({
x, y, visible, containerRef, containerWidth, containerHeight, offset = 16, className = "", children, top: topOverride,
}: TooltipBoxProps) {
const tooltipRef = useRef<HTMLDivElement>(null);
const [tooltipWidth, setTooltipWidth] = useState(180);
const [tooltipHeight, setTooltipHeight] = useState(80);
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
useLayoutEffect(() => {
if (tooltipRef.current) {
const w = tooltipRef.current.offsetWidth;
const h = tooltipRef.current.offsetHeight;
if (w > 0 && w !== tooltipWidth) setTooltipWidth(w);
if (h > 0 && h !== tooltipHeight) setTooltipHeight(h);
}
}, [tooltipWidth, tooltipHeight]);
const shouldFlipX = x + tooltipWidth + offset > containerWidth;
const targetX = shouldFlipX ? x - offset - tooltipWidth : x + offset;
const targetY = Math.max(offset, Math.min(y - tooltipHeight / 2, containerHeight - tooltipHeight - offset));
const prevFlipRef = useRef(shouldFlipX);
const [flipKey, setFlipKey] = useState(0);
useEffect(() => {
if (prevFlipRef.current !== shouldFlipX) { setFlipKey((k) => k + 1); prevFlipRef.current = shouldFlipX; }
}, [shouldFlipX]);
const springConfig = { stiffness: 100, damping: 20 };
const animatedLeft = useSpring(targetX, springConfig);
const animatedTop = useSpring(targetY, springConfig);
useEffect(() => { animatedLeft.set(targetX); }, [targetX, animatedLeft]);
useEffect(() => { animatedTop.set(targetY); }, [targetY, animatedTop]);
const finalTop = topOverride ?? animatedTop;
const transformOrigin = shouldFlipX ? "right top" : "left top";
const container = containerRef.current;
if (!(mounted && container)) return null;
if (!visible) return null;
return createPortal(
<motion.div animate={{ opacity: 1 }} className={cn("pointer-events-none absolute z-50", className)} exit={{ opacity: 0 }} initial={{ opacity: 0 }} ref={tooltipRef} style={{ left: animatedLeft, top: finalTop }} transition={{ duration: 0.1 }}>
<motion.div animate={{ scale: 1, opacity: 1, x: 0 }} className="min-w-[140px] overflow-hidden rounded-lg bg-neutral-800/90 text-white shadow-lg backdrop-blur-md" initial={{ scale: 0.85, opacity: 0, x: shouldFlipX ? 20 : -20 }} key={flipKey} style={{ transformOrigin }} transition={{ type: "spring", stiffness: 300, damping: 25 }}>
{children}
</motion.div>
</motion.div>,
container
);
}
TooltipBox.displayName = "TooltipBox";
// ─── ChartTooltip ────────────────────────────────────────────────────────────
export interface ChartTooltipProps {
showCrosshair?: boolean;
showDots?: boolean;
content?: (props: { point: Record<string, unknown>; index: number }) => ReactNode;
rows?: (point: Record<string, unknown>) => TooltipRow[];
children?: ReactNode;
className?: string;
}
export function ChartTooltip({ showCrosshair = true, showDots = true, content, rows: rowsRenderer, children, className = "" }: ChartTooltipProps) {
const { tooltipData, width, height, innerHeight, margin, bars, xDataKey, containerRef, orientation, yScale } = useChart();
const isHorizontal = orientation === "horizontal";
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
const visible = tooltipData !== null;
const x = tooltipData?.x ?? 0;
const xWithMargin = x + margin.left;
const firstBarDataKey = bars[0]?.dataKey;
const firstBarY = firstBarDataKey ? (tooltipData?.yPositions[firstBarDataKey] ?? 0) : 0;
const yWithMargin = firstBarY + margin.top;
const springConfig = { stiffness: 300, damping: 30 };
const animatedX = useSpring(xWithMargin, springConfig);
useEffect(() => { animatedX.set(xWithMargin); }, [xWithMargin, animatedX]);
const tooltipRows = useMemo(() => {
if (!tooltipData) return [];
if (rowsRenderer) return rowsRenderer(tooltipData.point);
return bars.map((bar) => ({ color: bar.stroke || bar.fill, label: bar.dataKey, value: (tooltipData.point[bar.dataKey] as number) ?? 0 }));
}, [tooltipData, bars, rowsRenderer]);
const title = useMemo(() => {
if (!tooltipData) return undefined;
return String(tooltipData.point[xDataKey] ?? "");
}, [tooltipData, xDataKey]);
const container = containerRef.current;
if (!(mounted && container)) return null;
const tooltipContent = (
<>
{showCrosshair && !isHorizontal && (
<svg aria-hidden="true" className="pointer-events-none absolute inset-0" height="100%" width="100%">
<g transform={`translate(${margin.left},${margin.top})`}>
<TooltipIndicator height={innerHeight} visible={visible} width={1} x={x} />
</g>
</svg>
)}
{showDots && visible && !isHorizontal && (
<svg aria-hidden="true" className="pointer-events-none absolute inset-0" height="100%" width="100%">
<g transform={`translate(${margin.left},${margin.top})`}>
{bars.map((bar) => (
<TooltipDot color={bar.stroke || bar.fill} key={bar.dataKey} strokeColor={chartCssVars.background} visible={visible} x={tooltipData?.xPositions?.[bar.dataKey] ?? x} y={tooltipData?.yPositions[bar.dataKey] ?? 0} />
))}
</g>
</svg>
)}
<TooltipBox className={className} containerHeight={height} containerRef={containerRef} containerWidth={width} top={isHorizontal ? undefined : margin.top} visible={visible} x={xWithMargin} y={isHorizontal ? yWithMargin : margin.top}>
{content ? content({ point: tooltipData?.point ?? {}, index: tooltipData?.index ?? 0 }) : (
<TooltipContent rows={tooltipRows} title={title}>{children}</TooltipContent>
)}
</TooltipBox>
</>
);
return createPortal(tooltipContent, container);
}
ChartTooltip.displayName = "ChartTooltip";
// ─── Grid ────────────────────────────────────────────────────────────────────
export interface GridProps {
horizontal?: boolean;
vertical?: boolean;
numTicksRows?: number;
numTicksColumns?: number;
rowTickValues?: number[];
stroke?: string;
strokeOpacity?: number;
strokeWidth?: number;
strokeDasharray?: string;
fadeHorizontal?: boolean;
fadeVertical?: boolean;
}
export function Grid({
horizontal = true, vertical = false, numTicksRows = 5, numTicksColumns = 10, rowTickValues,
stroke = chartCssVars.grid, strokeOpacity = 1, strokeWidth = 1, strokeDasharray = "4,4",
fadeHorizontal = true, fadeVertical = false,
}: GridProps) {
const { xScale, yScale, innerWidth, innerHeight, orientation } = useChart();
const isHorizontalBar = orientation === "horizontal";
const columnScale = isHorizontalBar ? yScale : xScale;
const uniqueId = useId();
const hMaskId = `grid-rows-fade-${uniqueId}`;
const hGradientId = `${hMaskId}-gradient`;
const vMaskId = `grid-cols-fade-${uniqueId}`;
const vGradientId = `${vMaskId}-gradient`;
return (
<g className="chart-grid">
{horizontal && fadeHorizontal && (
<defs>
<linearGradient id={hGradientId} x1="0%" x2="100%" y1="0%" y2="0%">
<stop offset="0%" style={{ stopColor: "white", stopOpacity: 0 }} />
<stop offset="10%" style={{ stopColor: "white", stopOpacity: 1 }} />
<stop offset="90%" style={{ stopColor: "white", stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: "white", stopOpacity: 0 }} />
</linearGradient>
<mask id={hMaskId}><rect fill={`url(#${hGradientId})`} height={innerHeight} width={innerWidth} x="0" y="0" /></mask>
</defs>
)}
{vertical && fadeVertical && (
<defs>
<linearGradient id={vGradientId} x1="0%" x2="0%" y1="0%" y2="100%">
<stop offset="0%" style={{ stopColor: "white", stopOpacity: 0 }} />
<stop offset="10%" style={{ stopColor: "white", stopOpacity: 1 }} />
<stop offset="90%" style={{ stopColor: "white", stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: "white", stopOpacity: 0 }} />
</linearGradient>
<mask id={vMaskId}><rect fill={`url(#${vGradientId})`} height={innerHeight} width={innerWidth} x="0" y="0" /></mask>
</defs>
)}
{horizontal && (
<g mask={fadeHorizontal ? `url(#${hMaskId})` : undefined}>
<GridRows numTicks={rowTickValues ? undefined : numTicksRows} scale={yScale} stroke={stroke} strokeDasharray={strokeDasharray} strokeOpacity={strokeOpacity} strokeWidth={strokeWidth} tickValues={rowTickValues} width={innerWidth} />
</g>
)}
{vertical && columnScale && typeof columnScale === "function" && (
<g mask={fadeVertical ? `url(#${vMaskId})` : undefined}>
<GridColumns height={innerHeight} numTicks={numTicksColumns} scale={columnScale} stroke={stroke} strokeDasharray={strokeDasharray} strokeOpacity={strokeOpacity} strokeWidth={strokeWidth} />
</g>
)}
</g>
);
}
Grid.displayName = "Grid";
// ─── BarXAxis ────────────────────────────────────────────────────────────────
export interface BarXAxisProps {
tickerHalfWidth?: number;
showAllLabels?: boolean;
maxLabels?: number;
}
export function BarXAxis({ tickerHalfWidth = 50, showAllLabels = false, maxLabels = 12 }: BarXAxisProps) {
const { xScale, margin, tooltipData, containerRef, bandWidth } = useChart();
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
const labelsToShow = useMemo(() => {
const domain = xScale.domain();
if (domain.length === 0) return [];
let labels = domain.map((label) => ({ label, x: (xScale(label) ?? 0) + bandWidth / 2 + margin.left }));
if (!showAllLabels && labels.length > maxLabels) {
const step = Math.ceil(labels.length / maxLabels);
labels = labels.filter((_, i) => i % step === 0);
}
return labels;
}, [xScale, margin.left, bandWidth, showAllLabels, maxLabels]);
const isHovering = tooltipData !== null;
const crosshairX = tooltipData ? tooltipData.x + margin.left : null;
const container = containerRef.current;
if (!(mounted && container)) return null;
return createPortal(
<div className="pointer-events-none absolute inset-0">
{labelsToShow.map((item) => {
let opacity = 1;
if (isHovering && crosshairX !== null) {
const fadeBuffer = 20;
const fadeRadius = tickerHalfWidth + fadeBuffer;
const distance = Math.abs(item.x - crosshairX);
if (distance < tickerHalfWidth) opacity = 0;
else if (distance < fadeRadius) opacity = (distance - tickerHalfWidth) / fadeBuffer;
}
return (
<div className="absolute" key={item.label} style={{ left: item.x, bottom: 12, width: 0, display: "flex", justifyContent: "center" }}>
<motion.span animate={{ opacity }} className="whitespace-nowrap text-neutral-500 text-xs" initial={{ opacity: 1 }} transition={{ duration: 0.4, ease: "easeInOut" }}>
{item.label}
</motion.span>
</div>
);
})}
</div>,
container
);
}
BarXAxis.displayName = "BarXAxis";
// ─── BarValueAxis (numeric Y-axis for vertical bar charts) ───────────────
export interface BarValueAxisProps {
numTicks?: number;
formatValue?: (value: number) => string;
}
export function BarValueAxis({ numTicks = 5, formatValue }: BarValueAxisProps) {
const { yScale, margin, containerRef } = useChart();
const [container, setContainer] = useState<HTMLDivElement | null>(null);
useEffect(() => { setContainer(containerRef.current); }, [containerRef]);
const ticks = useMemo(() => {
const domain = yScale.domain() as [number, number];
const min = domain[0];
const max = domain[1];
const step = (max - min) / (numTicks - 1);
return Array.from({ length: numTicks }, (_, i) => {
const value = min + step * i;
return {
value,
y: (yScale(value) ?? 0) + margin.top,
label: formatValue ? formatValue(value) : value >= 1000 ? `${(value / 1000).toFixed(value % 1000 === 0 ? 0 : 1)}k` : Math.round(value).toLocaleString(),
};
});
}, [yScale, margin.top, numTicks, formatValue]);
if (!container) return null;
return createPortal(
<div className="pointer-events-none absolute inset-0">
{ticks.map((tick) => (
<div key={tick.value} className="absolute" style={{ left: 0, top: tick.y, width: margin.left - 8, display: "flex", justifyContent: "flex-end", transform: "translateY(-50%)" }}>
<span className="whitespace-nowrap text-neutral-500 text-xs tabular-nums">{tick.label}</span>
</div>
))}
</div>,
container
);
}
BarValueAxis.displayName = "BarValueAxis";
// ─── Bar ─────────────────────────────────────────────────────────────────────
export interface BarProps {
dataKey: string;
fill?: string;
stroke?: string;
lineCap?: "round" | "butt" | number;
animate?: boolean;
animationType?: "grow" | "fade";
fadedOpacity?: number;
staggerDelay?: number;
stackGap?: number;
}
function resolveRadius(lineCap: "round" | "butt" | number, barWidth: number): number {
if (lineCap === "butt") return 0;
if (lineCap === "round") return barWidth / 2;
return lineCap;
}
export function Bar({
dataKey, fill = chartCssVars.linePrimary, stroke, lineCap = "round", animate = true,
animationType = "grow", fadedOpacity = 0.3, staggerDelay, stackGap = 0,
}: BarProps) {
const {
data, xScale, yScale, innerHeight, innerWidth, bandWidth, hoveredBarIndex, isLoaded, animationDuration,
xDataKey, orientation, stacked, stackOffsets, bars, barWidth: fixedBarWidth,
} = useChart();
const isHorizontal = orientation === "horizontal";
const barIndex = bars.findIndex((b) => b.dataKey === dataKey);
const barCount = bars.length;
const singleBarWidth = stacked ? bandWidth : bandWidth / barCount;
const actualBarWidth = fixedBarWidth ?? singleBarWidth;
const radius = resolveRadius(lineCap, actualBarWidth);
const autoStagger = staggerDelay ?? Math.min(0.06, 0.8 / data.length);
return (
<>
{data.map((d, i) => {
const category = String(d[xDataKey] ?? "");
const value = typeof d[dataKey] === "number" ? (d[dataKey] as number) : 0;
const bandStart = xScale(category) ?? 0;
const stackOffset = stacked ? stackOffsets.get(i)?.get(dataKey) ?? 0 : 0;
let barX: number, barY: number, barW: number, barH: number;
if (isHorizontal) {
const barLength = innerWidth - (yScale(value) ?? innerWidth);
barY = bandStart + (stacked ? 0 : barIndex * singleBarWidth);
barH = actualBarWidth;
barW = barLength;
barX = stacked ? stackOffset : 0;
if (stacked && stackGap > 0 && barIndex > 0) { barX += stackGap; barW = Math.max(0, barW - stackGap); }
} else {
const scaledY = yScale(value) ?? innerHeight;
barX = bandStart + (stacked ? 0 : barIndex * singleBarWidth);
barW = actualBarWidth;
barH = innerHeight - scaledY;
barY = stacked ? scaledY - stackOffset : scaledY;
if (stacked && stackGap > 0 && barIndex > 0) { barY += stackGap; barH = Math.max(0, barH - stackGap); }
}
if (barW <= 0 || barH <= 0) return null;
const isHovered = hoveredBarIndex === i;
const someoneHovered = hoveredBarIndex !== null;
const barOpacity = someoneHovered ? (isHovered ? 1 : fadedOpacity) : 1;
const delay = i * autoStagger;
const r = Math.min(radius, barW / 2, barH / 2);
let path: string;
if (isHorizontal) {
path = `M${barX},${barY} L${barX + barW - r},${barY} Q${barX + barW},${barY} ${barX + barW},${barY + r} L${barX + barW},${barY + barH - r} Q${barX + barW},${barY + barH} ${barX + barW - r},${barY + barH} L${barX},${barY + barH}Z`;
} else {
path = `M${barX},${barY + barH} L${barX},${barY + r} Q${barX},${barY} ${barX + r},${barY} L${barX + barW - r},${barY} Q${barX + barW},${barY} ${barX + barW},${barY + r} L${barX + barW},${barY + barH}Z`;
}
const originX = isHorizontal ? barX : barX + barW / 2;
const originY = isHorizontal ? barY + barH / 2 : innerHeight;
const shouldAnimateEntry = animate && !isLoaded;
const growInitial = isHorizontal ? { scaleX: 0, opacity: 0 } : { scaleY: 0, opacity: 0 };
const growAnimate = isHorizontal ? { scaleX: 1, opacity: barOpacity } : { scaleY: 1, opacity: barOpacity };
const growTransition = {
[isHorizontal ? "scaleX" : "scaleY"]: { duration: animationDuration / 1000, ease: [0.85, 0, 0.15, 1] as [number, number, number, number], delay },
opacity: { duration: 0.3, ease: "easeInOut" as const },
};
return (
<motion.path
key={`${category}-${dataKey}`}
d={path}
fill={fill}
style={{ transformOrigin: `${originX}px ${originY}px` }}
initial={shouldAnimateEntry && animationType === "grow" ? growInitial : shouldAnimateEntry && animationType === "fade" ? { opacity: 0, filter: "blur(4px)" } : { opacity: barOpacity }}
animate={shouldAnimateEntry && animationType === "grow" ? growAnimate : shouldAnimateEntry && animationType === "fade" ? { opacity: barOpacity, filter: "blur(0px)" } : { opacity: barOpacity }}
transition={shouldAnimateEntry && animationType === "grow" ? growTransition : shouldAnimateEntry && animationType === "fade" ? { duration: 0.5, delay, ease: "easeOut" } : { opacity: { duration: 0.3, ease: "easeInOut" } }}
/>
);
})}
</>
);
}
Bar.displayName = "Bar";
// ─── Re-exports ──────────────────────────────────────────────────────────────
export { VisxLinearGradient as LinearGradient };
// ─── BarChart ────────────────────────────────────────────────────────────────
function extractBarConfigs(children: ReactNode): BarConfig[] {
const configs: BarConfig[] = [];
Children.forEach(children, (child) => {
if (!isValidElement(child)) return;
const childType = child.type as { displayName?: string; name?: string };
const componentName = typeof child.type === "function" ? childType.displayName || childType.name || "" : "";
const props = child.props as BarProps | undefined;
const isBarComponent = componentName === "Bar" || child.type === Bar || (props && typeof props.dataKey === "string" && props.dataKey.length > 0);
if (isBarComponent && props?.dataKey) {
configs.push({ dataKey: props.dataKey, fill: props.fill || "var(--chart-line-primary)", stroke: props.stroke });
}
});
return configs;
}
export interface BarChartProps {
data: Record<string, unknown>[];
xDataKey?: string;
margin?: Partial<Margin>;
animationDuration?: number;
aspectRatio?: string;
barGap?: number;
barWidth?: number;
orientation?: "vertical" | "horizontal";
stacked?: boolean;
stackGap?: number;
className?: string;
children: ReactNode;
}
const DEFAULT_MARGIN: Margin = { top: 40, right: 40, bottom: 40, left: 40 };
interface BarChartInnerProps {
width: number;
height: number;
data: Record<string, unknown>[];
xDataKey: string;
margin: Margin;
animationDuration: number;
barGap: number;
barWidth?: number;
orientation: "vertical" | "horizontal";
stacked: boolean;
stackGap: number;
children: ReactNode;
containerRef: RefObject<HTMLDivElement | null>;
}
function BarChartInner({
width, height, data, xDataKey, margin, animationDuration, barGap, barWidth, orientation, stacked, stackGap, children, containerRef,
}: BarChartInnerProps) {
const [isLoaded, setIsLoaded] = useState(false);
const [hoveredBarIndex, setHoveredBarIndex] = useState<number | null>(null);
const bars = useMemo(() => extractBarConfigs(children), [children]);
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const isHorizontal = orientation === "horizontal";
const xScale = useMemo(() => {
const domain = data.map((d) => String(d[xDataKey] ?? ""));
return scaleBand<string>({ range: isHorizontal ? [0, innerHeight] : [0, innerWidth], domain, padding: barGap });
}, [data, xDataKey, innerWidth, innerHeight, barGap, isHorizontal]);
const bandWidth = xScale.bandwidth();
const yScale = useMemo(() => {
let maxValue = 0;
if (stacked) {
for (const d of data) { let sum = 0; for (const bar of bars) { const v = d[bar.dataKey]; if (typeof v === "number") sum += v; } if (sum > maxValue) maxValue = sum; }
} else {
for (const bar of bars) { for (const d of data) { const v = d[bar.dataKey]; if (typeof v === "number" && v > maxValue) maxValue = v; } }
}
if (maxValue === 0) maxValue = 100;
return scaleLinear<number>({ range: isHorizontal ? [innerWidth, 0] : [innerHeight, 0], domain: [0, maxValue * 1.1], nice: true });
}, [data, bars, innerWidth, innerHeight, stacked, isHorizontal]);
const stackOffsets = useMemo(() => {
if (!stacked) return new Map<number, Map<string, number>>();
const offsets = new Map<number, Map<string, number>>();
for (let i = 0; i < data.length; i++) {
const d = data[i]!;
let cumulative = 0;
const barOffsets = new Map<string, number>();
for (const bar of bars) {
barOffsets.set(bar.dataKey, cumulative);
const v = d[bar.dataKey];
if (typeof v === "number") { cumulative += isHorizontal ? innerWidth - (yScale(v) ?? innerWidth) : innerHeight - (yScale(v) ?? innerHeight); }
}
offsets.set(i, barOffsets);
}
return offsets;
}, [data, bars, stacked, yScale, innerHeight, innerWidth, isHorizontal]);
const [tooltipData, setTooltipData] = useState<TooltipData | null>(null);
useEffect(() => { const timer = setTimeout(() => setIsLoaded(true), animationDuration); return () => clearTimeout(timer); }, [animationDuration]);
const handleMouseMove = useCallback((event: React.MouseEvent<SVGGElement>) => {
const point = localPoint(event);
if (!point) return;
const chartX = point.x - margin.left;
const chartY = point.y - margin.top;
const domain = xScale.domain();
let foundIndex = -1;
for (let i = 0; i < domain.length; i++) {
const cat = domain[i]!;
const bandStart = xScale(cat) ?? 0;
const bandEnd = bandStart + bandWidth;
if (isHorizontal ? (chartY >= bandStart && chartY <= bandEnd) : (chartX >= bandStart && chartX <= bandEnd)) { foundIndex = i; break; }
}
if (foundIndex >= 0) {
setHoveredBarIndex(foundIndex);
const d = data[foundIndex]!;
const yPositions: Record<string, number> = {};
const xPositions: Record<string, number> = {};
for (const bar of bars) {
const v = d[bar.dataKey];
if (typeof v === "number") {
if (isHorizontal) { xPositions[bar.dataKey] = innerWidth - (yScale(v) ?? innerWidth); yPositions[bar.dataKey] = (xScale(domain[foundIndex]!) ?? 0) + bandWidth / 2; }
else { yPositions[bar.dataKey] = yScale(v) ?? 0; xPositions[bar.dataKey] = (xScale(domain[foundIndex]!) ?? 0) + bandWidth / 2; }
}
}
const tooltipX = isHorizontal ? innerWidth - (yScale(Number(d[bars[0]?.dataKey ?? ""] ?? 0)) ?? 0) : (xScale(domain[foundIndex]!) ?? 0) + bandWidth / 2;
setTooltipData({ point: d, index: foundIndex, x: tooltipX, yPositions, xPositions });
} else { setHoveredBarIndex(null); setTooltipData(null); }
}, [xScale, yScale, data, bars, margin, bandWidth, isHorizontal, innerWidth]);
const handleMouseLeave = useCallback(() => { setHoveredBarIndex(null); setTooltipData(null); }, []);
if (width < 10 || height < 10) return null;
const contextValue: BarChartContextValue = {
data, xScale, yScale, width, height, innerWidth, innerHeight, margin, bandWidth, tooltipData, setTooltipData, containerRef, bars, isLoaded, animationDuration, xDataKey, hoveredBarIndex, setHoveredBarIndex, orientation, stacked, stackGap, stackOffsets, barGap, barWidth,
};
return (
<BarChartProvider value={contextValue}>
<svg aria-hidden="true" height={height} width={width}>
<rect fill="transparent" height={height} width={width} x={0} y={0} />
<g onMouseMove={isLoaded ? handleMouseMove : undefined} onMouseLeave={isLoaded ? handleMouseLeave : undefined} style={{ cursor: isLoaded ? "crosshair" : "default", touchAction: "none" }} transform={`translate(${margin.left},${margin.top})`}>
<rect fill="transparent" height={innerHeight} width={innerWidth} x={0} y={0} />
{children}
</g>
</svg>
</BarChartProvider>
);
}
export function BarChart({
data, xDataKey = "name", margin: marginProp, animationDuration = 1100, aspectRatio = "2 / 1",
barGap = 0.2, barWidth, orientation = "vertical", stacked = false, stackGap = 0, className = "", children,
}: BarChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const margin = { ...DEFAULT_MARGIN, ...marginProp };
return (
<div className={cn("relative w-full", className)} ref={containerRef} style={{ aspectRatio, touchAction: "none" }}>
<ParentSize debounceTime={10}>
{({ width, height }) => (
<BarChartInner animationDuration={animationDuration} barGap={barGap} barWidth={barWidth} containerRef={containerRef} data={data} height={height} margin={margin} orientation={orientation} stacked={stacked} stackGap={stackGap} width={width} xDataKey={xDataKey}>
{children}
</BarChartInner>
)}
</ParentSize>
</div>
);
}
export default BarChart;

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
},
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -26,7 +26,7 @@ const useCardContext = () => {
const cardVariants = cva('flex flex-col items-stretch text-card-foreground rounded-xl', {
variants: {
variant: {
default: 'bg-card border border-border shadow-xs black/5',
default: 'bg-neutral-900/80 border border-white/[0.08] backdrop-blur-sm',
accent: 'bg-muted shadow-xs p-1',
},
},

View File

@@ -145,8 +145,8 @@ function hSegmentPath(
straight = false
) {
const my = H / 2;
const h0 = normStart * H * 0.44 * layerScale;
const h1 = normEnd * H * 0.44 * layerScale;
const h0 = normStart * H * 0.3 * layerScale;
const h1 = normEnd * H * 0.3 * layerScale;
if (straight) {
return `M 0 ${my - h0} L ${segW} ${my - h1} L ${segW} ${my + h1} L 0 ${my + h0} Z`;

View File

@@ -0,0 +1,54 @@
'use client';
import React from 'react';
import { cn } from '@/lib/utils';
type MenuToggleProps = React.ComponentProps<'svg'> & {
open: boolean;
duration?: number;
};
export function MenuToggleIcon({
open,
className,
fill = 'none',
stroke = 'currentColor',
strokeWidth = 2.5,
strokeLinecap = 'round',
strokeLinejoin = 'round',
duration = 500,
...props
}: MenuToggleProps) {
return (
<svg
strokeWidth={strokeWidth}
fill={fill}
stroke={stroke}
viewBox="0 0 32 32"
strokeLinecap={strokeLinecap}
strokeLinejoin={strokeLinejoin}
className={cn(
'transition-transform ease-in-out',
open && '-rotate-45',
className,
)}
style={{
transitionDuration: `${duration}ms`,
}}
{...props}
>
<path
className={cn(
'transition-all ease-in-out',
open
? '[stroke-dasharray:20_300] [stroke-dashoffset:-32.42px]'
: '[stroke-dasharray:12_63]',
)}
style={{
transitionDuration: `${duration}ms`,
}}
d="M27 10 13 10C10.8 10 9 8.2 9 6 9 3.5 10.8 2 13 2 15.2 2 17 3.8 17 6L17 26C17 28.2 18.8 30 21 30 23.2 30 25 28.2 25 26 25 23.8 23.2 22 21 22L7 22"
/>
<path d="M7 16 27 16" />
</svg>
);
}

View File

@@ -0,0 +1,128 @@
import * as React from "react"
import { ChevronDownIcon } from "@radix-ui/react-icons"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-xl border border-white/[0.08] bg-neutral-900/65 text-popover-foreground shadow-xl backdrop-blur-3xl backdrop-saturate-150 supports-[backdrop-filter]:bg-neutral-900/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:duration-150 data-[state=closed]:duration-100 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}

56
lib/api/bot-filter.ts Normal file
View File

@@ -0,0 +1,56 @@
import apiRequest from './client'
export interface SessionSummary {
session_id: string
pageviews: number
duration: number | null
first_page: string
referrer: string | null
country: string | null
city: string | null
region: string | null
browser: string | null
os: string | null
screen_resolution: string | null
first_seen: string
bot_filtered: boolean
suspicion_score: number
}
export interface BotFilterStats {
filtered_sessions: number
filtered_events: number
auto_blocked_this_month: number
}
function buildQuery(opts: { startDate?: string; endDate?: string; suspicious?: boolean; limit?: number }): string {
const params = new URLSearchParams()
if (opts.startDate) params.append('start_date', opts.startDate)
if (opts.endDate) params.append('end_date', opts.endDate)
if (opts.suspicious) params.append('suspicious', 'true')
if (opts.limit != null) params.append('limit', opts.limit.toString())
const q = params.toString()
return q ? `?${q}` : ''
}
export function listSessions(siteId: string, startDate: string, endDate: string, suspiciousOnly?: boolean, limit?: number): Promise<{ sessions: SessionSummary[] }> {
return apiRequest<{ sessions: SessionSummary[] }>(`/sites/${siteId}/sessions${buildQuery({ startDate, endDate, suspicious: suspiciousOnly, limit })}`)
}
export function botFilterSessions(siteId: string, sessionIds: string[]): Promise<{ updated: number }> {
return apiRequest<{ updated: number }>(`/sites/${siteId}/bot-filter`, {
method: 'POST',
body: JSON.stringify({ session_ids: sessionIds }),
})
}
export function botUnfilterSessions(siteId: string, sessionIds: string[]): Promise<{ updated: number }> {
return apiRequest<{ updated: number }>(`/sites/${siteId}/bot-filter`, {
method: 'DELETE',
body: JSON.stringify({ session_ids: sessionIds }),
})
}
export function getBotFilterStats(siteId: string): Promise<BotFilterStats> {
return apiRequest<BotFilterStats>(`/sites/${siteId}/bot-filter/stats`)
}

View File

@@ -238,8 +238,9 @@ async function apiRequest<T>(
if (response.status === 401) {
// * Attempt Token Refresh if 401
if (typeof window !== 'undefined') {
// * Prevent infinite loop: Don't refresh if the failed request WAS a refresh request (unlikely via apiRequest but safe to check)
if (!endpoint.includes('/auth/refresh')) {
// * Skip token refresh for public endpoints (they use password auth, not session tokens)
// * and for refresh requests themselves (prevent infinite loop)
if (!endpoint.includes('/auth/refresh') && !endpoint.includes('/public/')) {
if (isRefreshing) {
// * If refresh is already in progress, wait for it to complete (or fail)
return new Promise<T>((resolve, reject) => {

View File

@@ -10,6 +10,7 @@ export interface ReportSchedule {
timezone: string
enabled: boolean
report_type: 'summary' | 'pages' | 'sources' | 'goals'
purpose: 'report' | 'alert'
send_hour: number
send_day: number | null
next_send_at: string | null
@@ -33,6 +34,7 @@ export interface CreateReportScheduleRequest {
frequency: string
timezone?: string
report_type?: string
purpose?: 'report' | 'alert'
send_hour?: number
send_day?: number
}
@@ -43,6 +45,7 @@ export interface UpdateReportScheduleRequest {
frequency?: string
timezone?: string
report_type?: string
purpose?: 'report' | 'alert'
enabled?: boolean
send_hour?: number
send_day?: number
@@ -73,6 +76,11 @@ export async function deleteReportSchedule(siteId: string, scheduleId: string):
})
}
export async function listAlertSchedules(siteId: string): Promise<ReportSchedule[]> {
const res = await apiRequest<{ report_schedules: ReportSchedule[] }>(`/sites/${siteId}/report-schedules?purpose=alert`)
return res?.report_schedules ?? []
}
export async function testReportSchedule(siteId: string, scheduleId: string): Promise<void> {
await apiRequest(`/sites/${siteId}/report-schedules/${scheduleId}/test`, {
method: 'POST',

View File

@@ -23,6 +23,10 @@ export interface Site {
hide_unknown_locations?: boolean
// Data retention (months); 0 = keep forever
data_retention_months?: number
// Script feature toggles
script_features?: Record<string, unknown>
// Uptime monitoring toggle
uptime_enabled: boolean
is_verified?: boolean
created_at: string
updated_at: string
@@ -49,6 +53,10 @@ export interface UpdateSiteRequest {
collect_screen_resolution?: boolean
// Bot and noise filtering
filter_bots?: boolean
// Script feature toggles
script_features?: Record<string, unknown>
// Uptime monitoring toggle
uptime_enabled?: boolean
// Hide unknown locations from stats
hide_unknown_locations?: boolean
// Data retention (months); 0 = keep forever

View File

@@ -117,6 +117,21 @@ export interface FrustrationByPage {
unique_elements: number
}
// ─── Public Auth ─────────────────────────────────────────────────────
export function authenticatePublicDashboard(siteId: string, password: string, captchaToken?: string, captchaId?: string, captchaSolution?: string): Promise<{ status: string }> {
return apiRequest<{ status: string }>(`/public/sites/${siteId}/auth`, {
method: 'POST',
body: JSON.stringify({
password,
captcha_token: captchaToken || '',
captcha_id: captchaId || '',
captcha_solution: captchaSolution || '',
}),
credentials: 'include',
})
}
// ─── Helpers ────────────────────────────────────────────────────────
function appendAuthParams(params: URLSearchParams, auth?: AuthParams) {

View File

@@ -54,23 +54,6 @@ export interface UptimeStatusResponse {
total_monitors: number
}
export interface CreateMonitorRequest {
name: string
url: string
check_interval_seconds?: number
expected_status_code?: number
timeout_seconds?: number
}
export interface UpdateMonitorRequest {
name: string
url: string
check_interval_seconds?: number
expected_status_code?: number
timeout_seconds?: number
enabled?: boolean
}
/**
* Fetches the uptime status overview for all monitors of a site
*/
@@ -82,43 +65,6 @@ export async function getUptimeStatus(siteId: string, startDate?: string, endDat
return apiRequest<UptimeStatusResponse>(`/sites/${siteId}/uptime/status${query ? `?${query}` : ''}`)
}
/**
* Lists all uptime monitors for a site
*/
export async function listUptimeMonitors(siteId: string): Promise<UptimeMonitor[]> {
const res = await apiRequest<{ monitors: UptimeMonitor[] }>(`/sites/${siteId}/uptime/monitors`)
return res?.monitors ?? []
}
/**
* Creates a new uptime monitor
*/
export async function createUptimeMonitor(siteId: string, data: CreateMonitorRequest): Promise<UptimeMonitor> {
return apiRequest<UptimeMonitor>(`/sites/${siteId}/uptime/monitors`, {
method: 'POST',
body: JSON.stringify(data),
})
}
/**
* Updates an existing uptime monitor
*/
export async function updateUptimeMonitor(siteId: string, monitorId: string, data: UpdateMonitorRequest): Promise<UptimeMonitor> {
return apiRequest<UptimeMonitor>(`/sites/${siteId}/uptime/monitors/${monitorId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
/**
* Deletes an uptime monitor
*/
export async function deleteUptimeMonitor(siteId: string, monitorId: string): Promise<void> {
await apiRequest(`/sites/${siteId}/uptime/monitors/${monitorId}`, {
method: 'DELETE',
})
}
/**
* Fetches recent checks for a specific monitor
*/

View File

@@ -32,7 +32,8 @@ import type { Site } from '@/lib/api/sites'
import { listFunnels, type Funnel } from '@/lib/api/funnels'
import { getUptimeStatus, type UptimeStatusResponse } from '@/lib/api/uptime'
import { listGoals, type Goal } from '@/lib/api/goals'
import { listReportSchedules, type ReportSchedule } from '@/lib/api/report-schedules'
import { listReportSchedules, listAlertSchedules, type ReportSchedule } from '@/lib/api/report-schedules'
import { listSessions, getBotFilterStats, type SessionSummary, type BotFilterStats } from '@/lib/api/bot-filter'
import { getGSCStatus, getGSCOverview, getGSCTopQueries, getGSCTopPages, getGSCDailyTotals, getGSCNewQueries } from '@/lib/api/gsc'
import type { GSCStatus, GSCOverview, GSCQueryResponse, GSCPageResponse, GSCDailyTotal, GSCNewQueries } from '@/lib/api/gsc'
import { getBunnyStatus, getBunnyOverview, getBunnyDailyStats, getBunnyTopCountries } from '@/lib/api/bunny'
@@ -80,6 +81,7 @@ const fetchers = {
uptimeStatus: (siteId: string) => getUptimeStatus(siteId),
goals: (siteId: string) => listGoals(siteId),
reportSchedules: (siteId: string) => listReportSchedules(siteId),
alertSchedules: (siteId: string) => listAlertSchedules(siteId),
gscStatus: (siteId: string) => getGSCStatus(siteId),
gscOverview: (siteId: string, start: string, end: string) => getGSCOverview(siteId, start, end),
gscTopQueries: (siteId: string, start: string, end: string, limit: number, offset: number) => getGSCTopQueries(siteId, start, end, limit, offset),
@@ -410,6 +412,19 @@ export function useReportSchedules(siteId: string) {
)
}
// * Hook for alert schedules (uptime alerts)
export function useAlertSchedules(siteId: string) {
return useSWR<ReportSchedule[]>(
siteId ? ['alertSchedules', siteId] : null,
() => fetchers.alertSchedules(siteId),
{
...dashboardSWRConfig,
refreshInterval: 60 * 1000,
dedupingInterval: 10 * 1000,
}
)
}
// * Hook for GSC connection status
export function useGSCStatus(siteId: string) {
return useSWR<GSCStatus>(
@@ -517,5 +532,23 @@ export function useSubscription() {
)
}
// * Hook for session list (bot review)
export function useSessions(siteId: string, startDate: string, endDate: string, suspiciousOnly: boolean) {
return useSWR<{ sessions: SessionSummary[] }>(
siteId && startDate && endDate ? ['sessions', siteId, startDate, endDate, suspiciousOnly] : null,
() => listSessions(siteId, startDate, endDate, suspiciousOnly),
{ ...dashboardSWRConfig, refreshInterval: 0, dedupingInterval: 10 * 1000 }
)
}
// * Hook for bot filter stats
export function useBotFilterStats(siteId: string) {
return useSWR<BotFilterStats>(
siteId ? ['botFilterStats', siteId] : null,
() => getBotFilterStats(siteId),
{ ...dashboardSWRConfig, refreshInterval: 60 * 1000, dedupingInterval: 10 * 1000 }
)
}
// * Re-export for convenience
export { fetchers }

View File

@@ -5,22 +5,42 @@ import {
DeviceMobile,
DeviceTablet,
Desktop,
GoogleLogo,
FacebookLogo,
XLogo,
LinkedinLogo,
InstagramLogo,
GithubLogo,
YoutubeLogo,
RedditLogo,
Robot,
Link,
WhatsappLogo,
TelegramLogo,
SnapchatLogo,
PinterestLogo,
ThreadsLogo,
} from '@phosphor-icons/react'
import {
SiGoogle,
SiFacebook,
SiInstagram,
SiGithub,
SiYoutube,
SiReddit,
SiWhatsapp,
SiTelegram,
SiSnapchat,
SiPinterest,
SiThreads,
SiDuckduckgo,
SiBrave,
SiPerplexity,
SiAnthropic,
SiGooglegemini,
SiGithubcopilot,
SiDiscord,
} from '@icons-pack/react-simple-icons'
// Inline SVG icons for brands not in @icons-pack/react-simple-icons
function XIcon({ size = 16, color = '#fff' }: { size?: number; color?: string }) {
return <svg width={size} height={size} viewBox="0 0 24 24" fill={color}><path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"/></svg>
}
function LinkedInIcon({ size = 16, color = '#0A66C2' }: { size?: number; color?: string }) {
return <svg width={size} height={size} viewBox="0 0 24 24" fill={color}><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
}
function OpenAIIcon({ size = 16, color = '#fff' }: { size?: number; color?: string }) {
return <svg width={size} height={size} viewBox="0 0 24 24" fill={color}><path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/></svg>
}
function BingIcon({ size = 16, color = '#258FFA' }: { size?: number; color?: string }) {
return <svg width={size} height={size} viewBox="0 0 24 24" fill={color}><path d="M5.71 0v18.39l4.44 2.46 8.14-4.69v-4.71l-8.14-2.84V4.09L5.71 0zm4.44 11.19l4.39 1.53v2.78l-4.39 2.53v-6.84z"/></svg>
}
/**
* Google's public favicon service base URL.
@@ -97,33 +117,39 @@ export function getDeviceIcon(deviceName: string) {
return <Question className="text-neutral-400" />
}
const SI = { size: 16 } as const
export function getReferrerIcon(referrerName: string) {
if (!referrerName) return <Globe className="text-neutral-400" />
const lower = referrerName.toLowerCase()
if (lower.includes('google')) return <GoogleLogo className="text-blue-500" />
if (lower.includes('facebook')) return <FacebookLogo className="text-blue-600" />
if (lower.includes('twitter') || lower.includes('t.co') || lower.includes('x.com')) return <XLogo className="text-neutral-800 dark:text-neutral-200" />
if (lower.includes('linkedin')) return <LinkedinLogo className="text-blue-700" />
if (lower.includes('instagram')) return <InstagramLogo className="text-pink-600" />
if (lower.includes('github')) return <GithubLogo className="text-neutral-800 dark:text-neutral-200" />
if (lower.includes('youtube')) return <YoutubeLogo className="text-red-600" />
if (lower.includes('reddit')) return <RedditLogo className="text-orange-600" />
if (lower.includes('whatsapp')) return <WhatsappLogo className="text-green-500" />
if (lower.includes('telegram')) return <TelegramLogo className="text-blue-500" />
if (lower.includes('snapchat')) return <SnapchatLogo className="text-yellow-400" />
if (lower.includes('pinterest')) return <PinterestLogo className="text-red-600" />
if (lower.includes('threads')) return <ThreadsLogo className="text-neutral-800 dark:text-neutral-200" />
// AI assistants and search tools
if (lower.includes('chatgpt') || lower.includes('openai')) return <Robot className="text-neutral-800 dark:text-neutral-200" />
if (lower.includes('perplexity')) return <Robot className="text-teal-600" />
if (lower.includes('claude') || lower.includes('anthropic')) return <Robot className="text-orange-500" />
if (lower.includes('gemini')) return <Robot className="text-blue-500" />
if (lower.includes('copilot')) return <Robot className="text-blue-500" />
if (lower.includes('deepseek')) return <Robot className="text-blue-600" />
if (lower.includes('grok') || lower.includes('x.ai')) return <XLogo className="text-neutral-800 dark:text-neutral-200" />
if (lower.includes('phind')) return <Robot className="text-purple-600" />
if (lower.includes('you.com')) return <Robot className="text-indigo-600" />
// Shared Link (unattributed deep-page traffic)
// Social / platforms
if (lower.includes('google') && !lower.includes('gemini')) return <SiGoogle size={SI.size} color="#4285F4" />
if (lower.includes('facebook')) return <SiFacebook size={SI.size} color="#0866FF" />
if (lower.includes('twitter') || lower.includes('t.co') || lower.includes('x.com')) return <XIcon />
if (lower.includes('linkedin')) return <LinkedInIcon />
if (lower.includes('instagram')) return <SiInstagram size={SI.size} color="#E4405F" />
if (lower.includes('github')) return <SiGithub size={SI.size} color="#fff" />
if (lower.includes('youtube')) return <SiYoutube size={SI.size} color="#FF0000" />
if (lower.includes('reddit')) return <SiReddit size={SI.size} color="#FF4500" />
if (lower.includes('whatsapp')) return <SiWhatsapp size={SI.size} color="#25D366" />
if (lower.includes('telegram')) return <SiTelegram size={SI.size} color="#26A5E4" />
if (lower.includes('snapchat')) return <SiSnapchat size={SI.size} color="#FFFC00" />
if (lower.includes('pinterest')) return <SiPinterest size={SI.size} color="#BD081C" />
if (lower.includes('threads')) return <SiThreads size={SI.size} color="#fff" />
if (lower.includes('discord')) return <SiDiscord size={SI.size} color="#5865F2" />
// Search engines
if (lower.includes('bing')) return <BingIcon />
if (lower.includes('duckduckgo')) return <SiDuckduckgo size={SI.size} color="#DE5833" />
if (lower.includes('brave')) return <SiBrave size={SI.size} color="#FB542B" />
// AI assistants
if (lower.includes('chatgpt') || lower.includes('openai')) return <OpenAIIcon />
if (lower.includes('perplexity')) return <SiPerplexity size={SI.size} color="#1FB8CD" />
if (lower.includes('claude') || lower.includes('anthropic')) return <SiAnthropic size={SI.size} color="#D97757" />
if (lower.includes('gemini')) return <SiGooglegemini size={SI.size} color="#8E75B2" />
if (lower.includes('copilot')) return <SiGithubcopilot size={SI.size} color="#fff" />
if (lower.includes('deepseek')) return <OpenAIIcon color="#4D6BFE" />
if (lower.includes('grok') || lower.includes('x.ai')) return <XIcon />
// Shared Link
if (lower === 'shared link') return <Link className="text-neutral-500" />
return <Globe className="text-neutral-400" />
@@ -147,6 +173,8 @@ const REFERRER_DISPLAY_OVERRIDES: Record<string, string> = {
youtube: 'YouTube',
reddit: 'Reddit',
github: 'GitHub',
bing: 'Bing',
brave: 'Brave',
duckduckgo: 'DuckDuckGo',
whatsapp: 'WhatsApp',
telegram: 'Telegram',
@@ -246,12 +274,44 @@ export function mergeReferrersByDisplayName(
.sort((a, b) => b.pageviews - a.pageviews)
}
/** Domains that always use the custom X icon instead of favicon (avoids legacy bird). */
const REFERRER_USE_X_ICON = new Set(['t.co', 'x.com', 'twitter.com', 'www.twitter.com'])
/**
* Domains/labels where the Phosphor icon is better than Google's favicon service.
* For these, getReferrerFavicon returns null so the caller falls back to getReferrerIcon.
*/
const REFERRER_PREFER_ICON = new Set([
// Social / platforms
't.co', 'x.com', 'twitter.com', 'www.twitter.com',
'google.com', 'www.google.com',
'facebook.com', 'www.facebook.com', 'm.facebook.com', 'l.facebook.com',
'instagram.com', 'www.instagram.com', 'l.instagram.com',
'linkedin.com', 'www.linkedin.com',
'github.com', 'www.github.com',
'youtube.com', 'www.youtube.com', 'm.youtube.com',
'reddit.com', 'www.reddit.com', 'old.reddit.com',
'whatsapp.com', 'www.whatsapp.com', 'web.whatsapp.com',
'telegram.org', 'web.telegram.org', 't.me',
'snapchat.com', 'www.snapchat.com',
'pinterest.com', 'www.pinterest.com',
'threads.net', 'www.threads.net',
// Search engines
'bing.com', 'www.bing.com',
'duckduckgo.com', 'www.duckduckgo.com',
'search.brave.com', 'brave.com',
// AI assistants
'chatgpt.com', 'chat.openai.com', 'openai.com',
'perplexity.ai', 'www.perplexity.ai',
'claude.ai', 'www.claude.ai', 'anthropic.com',
'gemini.google.com',
'copilot.microsoft.com',
'deepseek.com', 'chat.deepseek.com',
'grok.x.ai', 'x.ai',
'phind.com', 'www.phind.com',
'you.com', 'www.you.com',
])
/**
* Returns a favicon URL for the referrer's domain, or null for non-URL referrers
* (e.g. "Direct", "Unknown") so callers can show an icon fallback instead.
* (e.g. "Direct", "Unknown") or known services where the Phosphor icon is better.
*/
export function getReferrerFavicon(referrer: string): string | null {
if (!referrer || typeof referrer !== 'string') return null
@@ -261,8 +321,13 @@ export function getReferrerFavicon(referrer: string): string | null {
if (!normalized.includes('.')) return null
try {
const url = new URL(referrer.startsWith('http') ? referrer : `https://${referrer}`)
if (REFERRER_USE_X_ICON.has(url.hostname.toLowerCase())) return null
return `${FAVICON_SERVICE_URL}?domain=${url.hostname}&sz=32`
const hostname = url.hostname.toLowerCase()
// Use Phosphor icon for known services — Google favicons are unreliable for these
if (REFERRER_PREFER_ICON.has(hostname)) return null
// Also check if the label matches a known referrer (catches subdomains like search.google.com)
const label = getReferrerLabel(hostname)
if (REFERRER_DISPLAY_OVERRIDES[label]) return null
return `${FAVICON_SERVICE_URL}?domain=${hostname}&sz=32`
} catch {
return null
}

1145
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,31 +12,45 @@
"test:watch": "vitest"
},
"dependencies": {
"@ciphera-net/ui": "^0.2.15",
"@ciphera-net/ui": "^0.3.1",
"@ducanh2912/next-pwa": "^10.2.9",
"@icons-pack/react-simple-icons": "^13.13.0",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-slot": "^1.2.4",
"@simplewebauthn/browser": "^13.2.2",
"@stripe/react-stripe-js": "^5.6.0",
"@stripe/stripe-js": "^8.7.0",
"@tanstack/react-virtual": "^3.13.21",
"@types/d3": "^7.4.3",
"@visx/curve": "^3.12.0",
"@visx/event": "^3.12.0",
"@visx/gradient": "^3.12.0",
"@visx/grid": "^3.12.0",
"@visx/responsive": "^3.12.0",
"@visx/scale": "^3.12.0",
"@visx/shape": "^3.12.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cobe": "^0.6.5",
"country-flag-icons": "^1.6.4",
"d3": "^7.9.0",
"d3-array": "^3.2.4",
"d3-scale": "^4.0.2",
"framer-motion": "^12.23.26",
"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",
"motion": "^12.35.2",
"next": "^16.1.1",
"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",
"sonner": "^2.0.7",
"svg-dotted-map": "^2.0.1",
@@ -52,8 +66,10 @@
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/d3-array": "^3.2.2",
"@types/d3-scale": "^4.0.9",
"@types/node": "^20.14.12",
"@types/react": "^19.2.14",

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

View File

@@ -10,29 +10,6 @@
--color-error: #EF4444;
/* * Chart colors */
--chart-1: #FD5E0F;
--chart-2: #3b82f6;
--chart-3: #22c55e;
--chart-4: #a855f7;
--chart-5: #f59e0b;
--chart-grid: #f5f5f5;
--chart-axis: #a3a3a3;
/* * shadcn-compatible semantic tokens (for 21st.dev components) */
--background: 255 255 255;
--foreground: 23 23 23;
--card: 255 255 255;
--card-foreground: 23 23 23;
--popover: 255 255 255;
--popover-foreground: 23 23 23;
--primary: 253 94 15;
--primary-foreground: 255 255 255;
--secondary: 245 245 245;
--secondary-foreground: 23 23 23;
--destructive-foreground: 255 255 255;
}
.dark {
--chart-1: #FD5E0F;
--chart-2: #60a5fa;
--chart-3: #4ade80;
@@ -41,7 +18,19 @@
--chart-grid: #262626;
--chart-axis: #737373;
/* * shadcn-compatible dark mode overrides */
/* * visx area chart tokens (dark-only) */
--chart-background: #0a0a0a;
--chart-foreground: #404040;
--chart-foreground-muted: #a3a3a3;
--chart-line-primary: #FD5E0F;
--chart-line-secondary: #737373;
--chart-crosshair: #404040;
--chart-label: #a3a3a3;
--chart-marker-background: #262626;
--chart-marker-border: #404040;
--chart-marker-foreground: #fafafa;
/* * shadcn-compatible semantic tokens (dark-only) */
--background: 10 10 10;
--foreground: 250 250 250;
--card: 23 23 23;
@@ -52,15 +41,13 @@
--primary-foreground: 255 255 255;
--secondary: 38 38 38;
--secondary-foreground: 250 250 250;
--accent: 38 38 38;
--accent-foreground: 250 250 250;
--destructive-foreground: 255 255 255;
}
body {
@apply bg-ciphera-gradient bg-fixed;
}
.dark body {
@apply bg-ciphera-gradient-dark;
@apply bg-neutral-950 text-neutral-100 antialiased;
}
}