feat: port interactive mockups from website and wire into feature sections

This commit is contained in:
Usman Baig
2026-03-21 19:49:19 +01:00
parent 0b7c4d528a
commit e789fb525b
7 changed files with 1923 additions and 82 deletions

View File

@@ -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={
<div className="p-6 sm:p-10 flex items-center justify-center min-h-[400px]">
<Image
src="/dashboard-preview-v2.png"
alt="Pulse analytics dashboard"
width={560}
height={400}
className="w-full h-auto rounded-xl"
unoptimized
/>
<div className="p-6 sm:p-10">
<PulseMockup />
</div>
}
/>
@@ -106,26 +102,8 @@ export default function FeatureSections() {
]}
reverse
mockup={
<div className="p-6 sm:p-10 flex items-center justify-center min-h-[400px]">
<div className="w-full space-y-4">
{/* 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) => (
<div key={item.page} className="space-y-1.5">
<div className="flex justify-between text-sm">
<span className="text-neutral-300 font-medium">{item.page}</span>
<span className="text-neutral-500">{item.pct}%</span>
</div>
<div className="h-2 bg-neutral-800 rounded-full overflow-hidden">
<div className="h-full bg-brand-orange rounded-full" style={{ width: `${item.pct}%` }} />
</div>
</div>
))}
</div>
<div className="p-6 sm:p-10">
<PulseFeaturesCarousel />
</div>
}
/>
@@ -143,30 +121,8 @@ export default function FeatureSections() {
'Configurable conversion window (up to 90 days)',
]}
mockup={
<div className="p-6 sm:p-10 flex items-center justify-center min-h-[400px]">
{/* Simple funnel visualization */}
<div className="w-full max-w-[300px] mx-auto space-y-2">
{[
{ 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) => (
<div key={step.label} className="text-center">
<div
className={`${step.color} rounded-lg py-3 mx-auto transition-all`}
style={{ width: `${step.pct}%` }}
>
<span className="text-white text-sm font-semibold">{step.value}</span>
</div>
<p className="text-xs text-neutral-400 mt-1">{step.label}</p>
{i < 3 && (
<p className="text-xs text-neutral-600 my-1"> {Math.round((1 - [1, 0.5, 0.3, 0.145][i + 1] / [1, 0.5, 0.3, 0.145][i]) * 100)}% drop-off</p>
)}
</div>
))}
<p className="text-center text-sm text-neutral-300 mt-4 font-medium">Overall conversion: <span className="text-brand-orange">14.5%</span></p>
</div>
<div className="p-6 sm:p-10">
<FunnelMockup />
</div>
}
/>
@@ -185,34 +141,8 @@ export default function FeatureSections() {
]}
reverse
mockup={
<div className="p-6 sm:p-10 flex items-center justify-center min-h-[400px]">
{/* Email mockup */}
<div className="w-full max-w-[360px] mx-auto bg-neutral-800/50 rounded-xl border border-neutral-700/40 p-5 space-y-4">
<div className="flex items-center gap-3 pb-3 border-b border-neutral-700/40">
<div className="w-8 h-8 rounded-lg bg-brand-orange/20 flex items-center justify-center">
<span className="text-brand-orange text-xs font-bold">P</span>
</div>
<div>
<p className="text-sm font-semibold text-white">Pulse Daily Report</p>
<p className="text-xs text-neutral-500">yoursite.com</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
{[
{ 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) => (
<div key={stat.label} className="bg-neutral-900/50 rounded-lg p-3">
<p className="text-xs text-neutral-500">{stat.label}</p>
<p className="text-lg font-bold text-white">{stat.value}</p>
<p className={`text-xs ${stat.change.startsWith('+') ? 'text-green-400' : 'text-brand-orange'}`}>{stat.change}</p>
</div>
))}
</div>
<p className="text-xs text-neutral-600 text-center">Delivered every day at 09:00</p>
</div>
<div className="p-6 sm:p-10">
<EmailReportMockup />
</div>
}
/>

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>
)
}