feat: port interactive mockups from website and wire into feature sections
This commit is contained in:
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
86
components/marketing/mockups/email-report-mockup.tsx
Normal file
86
components/marketing/mockups/email-report-mockup.tsx
Normal 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 · 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>
|
||||
)
|
||||
}
|
||||
935
components/marketing/mockups/funnel-chart.tsx
Normal file
935
components/marketing/mockups/funnel-chart.tsx
Normal 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;
|
||||
30
components/marketing/mockups/funnel-mockup.tsx
Normal file
30
components/marketing/mockups/funnel-mockup.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
119
components/marketing/mockups/modular-script-mockup.tsx
Normal file
119
components/marketing/mockups/modular-script-mockup.tsx
Normal 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 & dead clicks · 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 →</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>
|
||||
)
|
||||
}
|
||||
544
components/marketing/mockups/pulse-features-carousel.tsx
Normal file
544
components/marketing/mockups/pulse-features-carousel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
197
components/marketing/mockups/pulse-mockup.tsx
Normal file
197
components/marketing/mockups/pulse-mockup.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user