feat: add tabbed FAQ, polish installation code blocks, refine integration styling

This commit is contained in:
Usman Baig
2026-03-21 19:52:32 +01:00
parent e789fb525b
commit a361649e60
6 changed files with 307 additions and 97 deletions

View File

@@ -1,37 +1,18 @@
'use client' 'use client'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { useState } from 'react' import PulseFAQ from '@/components/marketing/PulseFAQ'
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."
}
]
// * JSON-LD FAQ Schema for rich snippets // * 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 = { const faqSchema = {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'FAQPage', '@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() { export default function FAQPage() {
return ( return (
<> <>
@@ -94,29 +34,9 @@ export default function FAQPage() {
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }} 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"> <div className="pt-8 pb-16">
{faqs.map((faq, index) => ( <PulseFAQ />
<FAQItem key={faq.question} faq={faq} index={index} />
))}
</div>
{/* * CTA */} {/* * CTA */}
<motion.div <motion.div
@@ -126,12 +46,12 @@ export default function FAQPage() {
transition={{ duration: 0.5, delay: 0.3 }} transition={{ duration: 0.5, delay: 0.3 }}
className="text-center mt-12" 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? Still have questions?
</p> </p>
<a <a
href="mailto:support@ciphera.net" 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 Contact us
</a> </a>

View File

@@ -31,7 +31,7 @@ export default function InstallationPage() {
<h2 className="text-2xl font-bold mb-8 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> <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 items-center px-4 py-3 bg-neutral-800 border-b border-neutral-800">
<div className="flex gap-2"> <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-red-500/20" />
@@ -53,6 +53,13 @@ export default function InstallationPage() {
<span className="text-blue-400">&gt;&lt;/script&gt;</span> <span className="text-blue-400">&gt;&lt;/script&gt;</span>
</code> </code>
</div> </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> </div>
@@ -61,7 +68,7 @@ export default function InstallationPage() {
<p className="text-neutral-500 mb-6 max-w-xl mx-auto"> <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-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> </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 items-center px-4 py-3 bg-neutral-800 border-b border-neutral-800">
<div className="flex gap-2"> <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-red-500/20" />

View File

@@ -143,7 +143,7 @@ export default function IntegrationsPage() {
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Search integrations..." placeholder="Search integrations..."
className="w-full pl-12 pr-16 py-3 bg-neutral-900/70 backdrop-blur-sm border border-neutral-800 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" 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 ? ( {query ? (
<button <button

View File

@@ -18,6 +18,7 @@ import { Cookie, ShieldCheck, Code, Lightning, ArrowRight, GithubLogo } from '@p
import FeatureSections from '@/components/marketing/FeatureSections' import FeatureSections from '@/components/marketing/FeatureSections'
import ComparisonCards from '@/components/marketing/ComparisonCards' import ComparisonCards from '@/components/marketing/ComparisonCards'
import CTASection from '@/components/marketing/CTASection' import CTASection from '@/components/marketing/CTASection'
import PulseFAQ from '@/components/marketing/PulseFAQ'
import { toast } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui'
import { getSitesLimitForPlan } from '@/lib/plans' import { getSitesLimitForPlan } from '@/lib/plans'
@@ -221,6 +222,7 @@ export default function HomePage() {
<FeatureSections /> <FeatureSections />
<ComparisonCards /> <ComparisonCards />
<PulseFAQ />
<CTASection /> <CTASection />
</> </>
) )

View File

@@ -0,0 +1,169 @@
'use client';
import React, { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { Plus } from 'lucide-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,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}
/>
)
}