diff --git a/components/marketing/FeatureSections.tsx b/components/marketing/FeatureSections.tsx index b90eec0..8d7da48 100644 --- a/components/marketing/FeatureSections.tsx +++ b/components/marketing/FeatureSections.tsx @@ -1,8 +1,11 @@ 'use client' import { motion } from 'framer-motion' -import Image from 'next/image' 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({ @@ -80,15 +83,8 @@ export default function FeatureSections() { 'Country-level geographic breakdown', ]} mockup={ -
- Pulse analytics dashboard +
+
} /> @@ -106,26 +102,8 @@ export default function FeatureSections() { ]} reverse mockup={ -
-
- {/* Mini mockup: top pages bars */} - {[ - { page: '/blog/privacy-guide', pct: 85 }, - { page: '/docs/getting-started', pct: 65 }, - { page: '/pricing', pct: 45 }, - { page: '/about', pct: 30 }, - ].map((item) => ( -
-
- {item.page} - {item.pct}% -
-
-
-
-
- ))} -
+
+
} /> @@ -143,30 +121,8 @@ export default function FeatureSections() { 'Configurable conversion window (up to 90 days)', ]} mockup={ -
- {/* Simple funnel visualization */} -
- {[ - { label: 'Landing Page', value: '2,847', pct: 100, color: 'bg-brand-orange' }, - { label: 'Sign Up Page', value: '1,423', pct: 50, color: 'bg-brand-orange/80' }, - { label: 'Onboarding', value: '856', pct: 30, color: 'bg-brand-orange/60' }, - { label: 'Activated', value: '412', pct: 14.5, color: 'bg-brand-orange/40' }, - ].map((step, i) => ( -
-
- {step.value} -
-

{step.label}

- {i < 3 && ( -

↓ {Math.round((1 - [1, 0.5, 0.3, 0.145][i + 1] / [1, 0.5, 0.3, 0.145][i]) * 100)}% drop-off

- )} -
- ))} -

Overall conversion: 14.5%

-
+
+
} /> @@ -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 */} +
+
+ + Pulse +
+
+
+ + {/* 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) => ( +
+
+
+
+
+ {row.views} +
+ {row.page} +
+ ))} +
+
+ + {/* 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. +

+
+
+

Recognition

+
+ Across all tabs + +
+
+
+

Reset after

+
+ 24 hours + +
+
+
+
+ + {/* 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 ( +
+
+ +

Referrers

+
+
+ {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 +
+
+
+ + + + + + + + + + + + + {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 ( + + ) + })} + +
+
+ ) +} + +// ─── 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 ( +
+
+
+ +

Peak Hours

+
+
+

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

+
+
+
+ 4 current visitors +
+
+
+ +
+ Today + + + +
+
+
+ + {/* Filter button */} +
+ +
+ + {/* Stats row */} +
+ {/* Unique Visitors — selected/highlighted */} +
+
+

Unique Visitors

+
+

247

+ + + + + 12% + +
+

vs yesterday

+
+ + {/* Total Pageviews */} +
+

Total Pageviews

+
+

512

+ + + + + 23% + +
+

vs yesterday

+
+ + {/* Bounce Rate */} +
+

Bounce Rate

+
+

68%

+ + + + + 8% + +
+

vs yesterday

+
+ + {/* Visit Duration */} +
+

Visit Duration

+
+

3m 18s

+ + + + + 15% + +
+

vs yesterday

+
+
+ + {/* Chart area */} +
+ {/* Chart header */} +
+ Unique Visitors +
+
+ 1 hour + + + +
+
+
+ Compare + + + + + + +
+
+
+ + {/* SVG Chart — step-style like the real dashboard */} +
+ {/* Y-axis labels */} +
+ 8 + 6 + 4 + 2 + 0 +
+ + {/* Chart */} + + {/* Grid lines */} + + + + + + + {/* Area fill — step-style chart */} + + + {/* Line — step-style */} + + + + + + + + + +
+ + {/* X-axis labels */} +
+ 01:00 + 04:00 + 07:00 + 10:00 + 13:00 + 16:00 + 19:00 +
+
+ + {/* Live indicator */} +
+
+ Live · 27 seconds ago +
+
+
+ ) +}