}
/>
@@ -185,34 +141,8 @@ export default function FeatureSections() {
]}
reverse
mockup={
-
- {/* Email mockup */}
-
-
-
- P
-
-
-
Pulse Daily Report
-
yoursite.com
-
-
-
- {[
- { label: 'Visitors', value: '1,247', change: '+12%' },
- { label: 'Pageviews', value: '3,891', change: '+8%' },
- { label: 'Bounce Rate', value: '42%', change: '-3%' },
- { label: 'Avg Duration', value: '2m 34s', change: '+15%' },
- ].map((stat) => (
-
-
{stat.label}
-
{stat.value}
-
{stat.change}
-
- ))}
-
-
Delivered every day at 09:00
-
+
+
}
/>
diff --git a/components/marketing/mockups/email-report-mockup.tsx b/components/marketing/mockups/email-report-mockup.tsx
new file mode 100644
index 0000000..74fe7bc
--- /dev/null
+++ b/components/marketing/mockups/email-report-mockup.tsx
@@ -0,0 +1,86 @@
+'use client'
+
+export function EmailReportMockup() {
+ return (
+
+
+ {/* Pulse logo header */}
+
+
+ {/* Report content */}
+
+
+
ciphera.net
+
Daily summary report · 19 Mar 2026
+
+
Traffic down 6% compared to yesterday
+
+ {/* Stats grid */}
+
+ {[
+ { 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) => (
+
+
{stat.label}
+
{stat.value}
+
+ {stat.down ? '\u25BC' : '\u25B2'} {stat.change}
+
+
+ ))}
+
+
+ {/* Divider */}
+
+
+ {/* Top Pages */}
+
Top Pages
+
+ Page
+ Views
+
+
+
+ {[
+ { page: '/', views: 100 },
+ { page: '/products/drop', views: 96 },
+ { page: '/pricing', views: 42 },
+ ].map((row) => (
+
+ ))}
+
+
+
+ {/* Schedule indicator */}
+
+
Delivered every day at 09:00
+
+
+ Sent
+
+
+
+
+
+ )
+}
diff --git a/components/marketing/mockups/funnel-chart.tsx b/components/marketing/mockups/funnel-chart.tsx
new file mode 100644
index 0000000..a234ad2
--- /dev/null
+++ b/components/marketing/mockups/funnel-chart.tsx
@@ -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 (
+
+ {background && (
+
+ )}
+
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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 (
+
+ );
+}
+
+// ─── 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 (
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+ );
+}
+
+// ─── 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 && (
+
+ {display}
+
+ );
+ const pctEl = showPercentage && (
+
+ {formatPercentage(pct)}
+
+ );
+ const labelEl = showLabels && (
+
+ {stage.label}
+
+ );
+
+ if (layout === "spread") {
+ return (
+
+ {isHorizontal ? (
+ <>
+
+ {valueEl}
+
+
+ {pctEl}
+
+
+ {labelEl}
+
+ >
+ ) : (
+ <>
+
+ {valueEl}
+
+
+ {pctEl}
+
+
+ {labelEl}
+
+ >
+ )}
+
+ );
+ }
+
+ // 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 (
+
+
+ {valueEl}
+ {pctEl}
+ {labelEl}
+
+
+ );
+}
+
+// ─── 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
(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 (
+
+ {W > 0 && H > 0 && (
+ <>
+ {/* Grid background bands */}
+ {gridEnabled && (
+
+ )}
+
+ {/* Segments */}
+
+ {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 ? (
+
+ ) : (
+
+ );
+ })}
+
+
+ {/* Grid lines */}
+ {gridEnabled && showGridLines && (
+
+ )}
+
+ {/* 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 (
+
setHoveredIndex(i)}
+ onMouseLeave={() => setHoveredIndex(null)}
+ style={{ ...posStyle, zIndex: 20 }}
+ transition={{ type: "spring", stiffness: 300, damping: 24 }}
+ >
+
+
+ );
+ })}
+ >
+ )}
+
+ );
+}
+
+FunnelChart.displayName = "FunnelChart";
+
+export default FunnelChart;
diff --git a/components/marketing/mockups/funnel-mockup.tsx b/components/marketing/mockups/funnel-mockup.tsx
new file mode 100644
index 0000000..169449e
--- /dev/null
+++ b/components/marketing/mockups/funnel-mockup.tsx
@@ -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 (
+
+
+
Funnel Visualization
+
+
+ Overall conversion: 7%
+ 7-day window
+
+
+
+ )
+}
diff --git a/components/marketing/mockups/modular-script-mockup.tsx b/components/marketing/mockups/modular-script-mockup.tsx
new file mode 100644
index 0000000..4c8425a
--- /dev/null
+++ b/components/marketing/mockups/modular-script-mockup.tsx
@@ -0,0 +1,119 @@
+'use client'
+
+function Toggle({ on = true }: { on?: boolean }) {
+ return (
+
+ )
+}
+
+export function ModularScriptMockup() {
+ return (
+
+
+ {/* Features heading */}
+
Features
+
+ {/* Feature toggles — 2 column grid */}
+
+ {[
+ { 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) => (
+
+
+
{feature.name}
+
{feature.desc}
+
+
+
+ ))}
+
+
+ {/* Frustration tracking — full width, disabled */}
+
+
+
Frustration tracking
+
Rage clicks & dead clicks · Loads separate add-on script
+
+
+
+
+ {/* Visitor identity */}
+
+
Visitor identity
+
+ How returning visitors are recognized. Stricter settings increase privacy but may raise unique visitor counts.
+
+
+
+
+ {/* Setup guide */}
+
+
+
Setup guide
+ All integrations →
+
+
+ {[
+ { name: 'Next.js', icon:
},
+ { name: 'React', icon:
},
+ { name: 'Vue.js', icon:
},
+ { name: 'Angular', icon:
},
+ { name: 'Svelte', icon:
},
+ { name: 'Nuxt', icon:
},
+ { name: 'Remix', icon:
},
+ { name: 'Astro', icon:
},
+ ].map((fw) => (
+
+ {fw.icon}
+ {fw.name}
+
+ ))}
+
+
+
+ {/* Verified status */}
+
+
+
+ Verified
+
+
Your site is sending data correctly.
+
+
+
+ )
+}
diff --git a/components/marketing/mockups/pulse-features-carousel.tsx b/components/marketing/mockups/pulse-features-carousel.tsx
new file mode 100644
index 0000000..be2def2
--- /dev/null
+++ b/components/marketing/mockups/pulse-features-carousel.tsx
@@ -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()
+ 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 = {
+ 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 (
+
+
+
+
+ {icon && {icon}}
+ {label}
+
+
+
{value}
+
+ )
+}
+
+// ─── 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 (
+
+
+
+
+
Pages
+
+
+ Top Pages
+ Entry
+ Exit
+
+
+
+ {data.map((d) => (
+
+ ))}
+
+
+ )
+}
+
+// ─── 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
+
+ )
+ }
+ const lower = name.toLowerCase()
+ if (lower === 'direct') return
+ if (lower.includes('google')) return
+ if (lower.includes('twitter') || lower.includes('x')) return
+ if (lower.includes('github')) return
+ if (lower.includes('youtube')) return
+ if (lower.includes('reddit')) return
+ if (lower.includes('hacker') || lower.includes('hn')) return
+ return
+}
+
+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 (
+
+
+
+ {data.map((d) => (
+
+ ))}
+
+
+ )
+}
+
+// ─── 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 (
+
+
+
+
+
Locations
+
+
+ Map
+ Countries
+ Regions
+ Cities
+
+
+
+
+
+
+ )
+}
+
+// ─── Card 4: Technology ──────────────────────────────────────────────────────
+
+const BROWSER_ICONS: Record = {
+ 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 (
+
+
+
+
+
Technology
+
+
+ Browsers
+ OS
+ Devices
+ Screens
+
+
+
+ {data.map((d) => (
+
+ ) : undefined
+ }
+ />
+ ))}
+
+
+ )
+}
+
+// ─── Card 5: Peak Hours (Exact Pulse Heatmap) ────────────────────────────────
+
+const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
+const BUCKETS = 12
+const BUCKET_LABELS: Record = { 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 (
+
+
+
When your visitors are most active
+
+
+ {MOCK_GRID.map((buckets, dayIdx) => (
+
+
+ {DAYS[dayIdx]}
+
+
+ {buckets.map((value, bucket) => {
+ const isBestCell = bestDay === dayIdx && bestBucket === bucket
+ return (
+
+ )
+ })}
+
+
+ ))}
+
+
+ {/* Hour axis labels */}
+
+
+
+ {Object.entries(BUCKET_LABELS).map(([b, label]) => (
+
+ {label}
+
+ ))}
+
+ 24:00
+
+
+
+
+ {/* Intensity legend */}
+
+
Less
+ {HIGHLIGHT_COLORS.map((color, i) => (
+
+ ))}
+
More
+
+
+
+ Your busiest time is{' '}
+
+ {['Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays', 'Sundays'][bestDay]} at {String(bestBucket * 2).padStart(2, '0')}:00
+
+
+
+ )
+}
+
+// ─── 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 (
+ setPaused(true)}
+ onMouseLeave={() => setPaused(false)}
+ >
+
+
+ {/* Dot indicators */}
+
+ {cards.map((card, i) => (
+
+
+ )
+}
diff --git a/components/marketing/mockups/pulse-mockup.tsx b/components/marketing/mockups/pulse-mockup.tsx
new file mode 100644
index 0000000..fc834b4
--- /dev/null
+++ b/components/marketing/mockups/pulse-mockup.tsx
@@ -0,0 +1,197 @@
+'use client'
+
+export function PulseMockup() {
+ return (
+
+
+ {/* Header row */}
+
+
+
+
Ciphera
+
ciphera.net
+
+
+
+
+
+
+ {/* Filter button */}
+
+
+ {/* Stats row */}
+
+ {/* Unique Visitors — selected/highlighted */}
+
+
+
Unique Visitors
+
+
vs yesterday
+
+
+ {/* Total Pageviews */}
+
+
Total Pageviews
+
+
vs yesterday
+
+
+ {/* Bounce Rate */}
+
+
Bounce Rate
+
+
vs yesterday
+
+
+ {/* Visit Duration */}
+
+
Visit Duration
+
+
3m 18s
+
+
+ 15%
+
+
+
vs yesterday
+
+
+
+ {/* Chart area */}
+
+ {/* Chart header */}
+
+
Unique Visitors
+
+
+
+
+
Compare
+
+
+
+
+
+
+ {/* SVG Chart — step-style like the real dashboard */}
+
+ {/* Y-axis labels */}
+
+ 8
+ 6
+ 4
+ 2
+ 0
+
+
+ {/* Chart */}
+
+
+
+ {/* X-axis labels */}
+
+ 01:00
+ 04:00
+ 07:00
+ 10:00
+ 13:00
+ 16:00
+ 19:00
+
+
+
+ {/* Live indicator */}
+
+
+
Live · 27 seconds ago
+
+
+
+ )
+}