545 lines
18 KiB
TypeScript
545 lines
18 KiB
TypeScript
'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>
|
|
)
|
|
}
|