feat: replace fake LiveDemo with real dashboard components and fake data
This commit is contained in:
@@ -15,7 +15,7 @@ import DeleteSiteModal from '@/components/sites/DeleteSiteModal'
|
||||
import { Button } from '@ciphera-net/ui'
|
||||
import { XIcon, GlobeIcon } from '@ciphera-net/ui'
|
||||
import { Cookie, ShieldCheck, Code, Lightning, ArrowRight, GithubLogo } from '@phosphor-icons/react'
|
||||
import LiveDemo from '@/components/marketing/LiveDemo'
|
||||
import DashboardDemo from '@/components/marketing/DashboardDemo'
|
||||
import FeatureSections from '@/components/marketing/FeatureSections'
|
||||
import ComparisonCards from '@/components/marketing/ComparisonCards'
|
||||
import CTASection from '@/components/marketing/CTASection'
|
||||
@@ -216,7 +216,7 @@ export default function HomePage() {
|
||||
transition={{ duration: 0.7, delay: 0.4 }}
|
||||
className="w-full max-w-7xl mx-auto px-6"
|
||||
>
|
||||
<LiveDemo />
|
||||
<DashboardDemo />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
|
||||
283
components/marketing/DashboardDemo.tsx
Normal file
283
components/marketing/DashboardDemo.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
'use client'
|
||||
|
||||
import Chart from '@/components/dashboard/Chart'
|
||||
import ContentStats from '@/components/dashboard/ContentStats'
|
||||
import TopReferrers from '@/components/dashboard/TopReferrers'
|
||||
import Locations from '@/components/dashboard/Locations'
|
||||
import TechSpecs from '@/components/dashboard/TechSpecs'
|
||||
import { useState } from 'react'
|
||||
|
||||
// ─── Fake Data ───────────────────────────────────────────────────────
|
||||
|
||||
const FAKE_STATS = { pageviews: 8432, visitors: 2847, bounce_rate: 42, avg_duration: 154 }
|
||||
const FAKE_PREV_STATS = { pageviews: 7821, visitors: 2543, bounce_rate: 45, avg_duration: 134 }
|
||||
|
||||
const FAKE_DAILY_STATS = [
|
||||
{ date: '2026-03-21 00:00:00', pageviews: 12, visitors: 8, bounce_rate: 45, avg_duration: 120 },
|
||||
{ date: '2026-03-21 01:00:00', pageviews: 8, visitors: 5, bounce_rate: 50, avg_duration: 98 },
|
||||
{ date: '2026-03-21 02:00:00', pageviews: 5, visitors: 3, bounce_rate: 55, avg_duration: 85 },
|
||||
{ date: '2026-03-21 03:00:00', pageviews: 3, visitors: 2, bounce_rate: 60, avg_duration: 72 },
|
||||
{ date: '2026-03-21 04:00:00', pageviews: 4, visitors: 3, bounce_rate: 58, avg_duration: 80 },
|
||||
{ date: '2026-03-21 05:00:00', pageviews: 18, visitors: 12, bounce_rate: 48, avg_duration: 110 },
|
||||
{ date: '2026-03-21 06:00:00', pageviews: 45, visitors: 32, bounce_rate: 44, avg_duration: 125 },
|
||||
{ date: '2026-03-21 07:00:00', pageviews: 98, visitors: 67, bounce_rate: 40, avg_duration: 140 },
|
||||
{ date: '2026-03-21 08:00:00', pageviews: 234, visitors: 156, bounce_rate: 38, avg_duration: 158 },
|
||||
{ date: '2026-03-21 09:00:00', pageviews: 412, visitors: 278, bounce_rate: 36, avg_duration: 172 },
|
||||
{ date: '2026-03-21 10:00:00', pageviews: 523, visitors: 342, bounce_rate: 35, avg_duration: 180 },
|
||||
{ date: '2026-03-21 11:00:00', pageviews: 578, visitors: 367, bounce_rate: 37, avg_duration: 175 },
|
||||
{ date: '2026-03-21 12:00:00', pageviews: 498, visitors: 312, bounce_rate: 40, avg_duration: 162 },
|
||||
{ date: '2026-03-21 13:00:00', pageviews: 612, visitors: 398, bounce_rate: 34, avg_duration: 185 },
|
||||
{ date: '2026-03-21 14:00:00', pageviews: 687, visitors: 445, bounce_rate: 32, avg_duration: 192 },
|
||||
{ date: '2026-03-21 15:00:00', pageviews: 721, visitors: 468, bounce_rate: 31, avg_duration: 198 },
|
||||
{ date: '2026-03-21 16:00:00', pageviews: 654, visitors: 423, bounce_rate: 33, avg_duration: 188 },
|
||||
{ date: '2026-03-21 17:00:00', pageviews: 543, visitors: 356, bounce_rate: 36, avg_duration: 170 },
|
||||
{ date: '2026-03-21 18:00:00', pageviews: 432, visitors: 287, bounce_rate: 39, avg_duration: 156 },
|
||||
{ date: '2026-03-21 19:00:00', pageviews: 367, visitors: 245, bounce_rate: 41, avg_duration: 148 },
|
||||
{ date: '2026-03-21 20:00:00', pageviews: 298, visitors: 198, bounce_rate: 43, avg_duration: 138 },
|
||||
{ date: '2026-03-21 21:00:00', pageviews: 234, visitors: 156, bounce_rate: 44, avg_duration: 130 },
|
||||
{ date: '2026-03-21 22:00:00', pageviews: 167, visitors: 112, bounce_rate: 46, avg_duration: 122 },
|
||||
{ date: '2026-03-21 23:00:00', pageviews: 89, visitors: 58, bounce_rate: 48, avg_duration: 115 },
|
||||
]
|
||||
|
||||
const FAKE_TOP_PAGES = [
|
||||
{ path: '/', pageviews: 2341, visits: 1892 },
|
||||
{ path: '/products/pulse', pageviews: 1567, visits: 1234 },
|
||||
{ path: '/products/drop', pageviews: 987, visits: 812 },
|
||||
{ path: '/pricing', pageviews: 876, visits: 723 },
|
||||
{ path: '/blog/privacy-first-analytics', pageviews: 654, visits: 543 },
|
||||
{ path: '/about', pageviews: 432, visits: 367 },
|
||||
{ path: '/docs/getting-started', pageviews: 389, visits: 312 },
|
||||
{ path: '/blog/end-to-end-encryption', pageviews: 345, visits: 289 },
|
||||
{ path: '/contact', pageviews: 287, visits: 234 },
|
||||
{ path: '/careers', pageviews: 198, visits: 167 },
|
||||
]
|
||||
|
||||
const FAKE_ENTRY_PAGES = [
|
||||
{ path: '/', pageviews: 1987, visits: 1654 },
|
||||
{ path: '/products/pulse', pageviews: 1123, visits: 987 },
|
||||
{ path: '/blog/privacy-first-analytics', pageviews: 567, visits: 489 },
|
||||
{ path: '/products/drop', pageviews: 534, visits: 456 },
|
||||
{ path: '/pricing', pageviews: 423, visits: 378 },
|
||||
{ path: '/docs/getting-started', pageviews: 312, visits: 267 },
|
||||
{ path: '/about', pageviews: 234, visits: 198 },
|
||||
{ path: '/blog/end-to-end-encryption', pageviews: 198, visits: 167 },
|
||||
{ path: '/careers', pageviews: 145, visits: 123 },
|
||||
{ path: '/contact', pageviews: 112, visits: 98 },
|
||||
]
|
||||
|
||||
const FAKE_EXIT_PAGES = [
|
||||
{ path: '/pricing', pageviews: 1456, visits: 1234 },
|
||||
{ path: '/', pageviews: 1234, visits: 1087 },
|
||||
{ path: '/contact', pageviews: 876, visits: 756 },
|
||||
{ path: '/products/drop', pageviews: 654, visits: 543 },
|
||||
{ path: '/products/pulse', pageviews: 567, visits: 478 },
|
||||
{ path: '/docs/getting-started', pageviews: 432, visits: 367 },
|
||||
{ path: '/about', pageviews: 345, visits: 289 },
|
||||
{ path: '/blog/privacy-first-analytics', pageviews: 298, visits: 245 },
|
||||
{ path: '/careers', pageviews: 234, visits: 198 },
|
||||
{ path: '/blog/end-to-end-encryption', pageviews: 178, visits: 145 },
|
||||
]
|
||||
|
||||
const FAKE_REFERRERS = [
|
||||
{ referrer: 'google.com', pageviews: 3421 },
|
||||
{ referrer: '(direct)', pageviews: 2100 },
|
||||
{ referrer: 'twitter.com', pageviews: 876 },
|
||||
{ referrer: 'github.com', pageviews: 654 },
|
||||
{ referrer: 'reddit.com', pageviews: 432 },
|
||||
{ referrer: 'producthunt.com', pageviews: 312 },
|
||||
{ referrer: 'news.ycombinator.com', pageviews: 267 },
|
||||
{ referrer: 'linkedin.com', pageviews: 198 },
|
||||
{ referrer: 'duckduckgo.com', pageviews: 112 },
|
||||
{ referrer: 'dev.to', pageviews: 78 },
|
||||
]
|
||||
|
||||
const FAKE_COUNTRIES = [
|
||||
{ country: 'CH', pageviews: 2534 },
|
||||
{ country: 'DE', pageviews: 1856 },
|
||||
{ country: 'US', pageviews: 1234 },
|
||||
{ country: 'FR', pageviews: 876 },
|
||||
{ country: 'GB', pageviews: 654 },
|
||||
{ country: 'NL', pageviews: 432 },
|
||||
{ country: 'AT', pageviews: 312 },
|
||||
{ country: 'SE', pageviews: 198 },
|
||||
{ country: 'JP', pageviews: 156 },
|
||||
{ country: 'CA', pageviews: 134 },
|
||||
]
|
||||
|
||||
const FAKE_CITIES = [
|
||||
{ city: 'Zurich', country: 'CH', pageviews: 1234 },
|
||||
{ city: 'Geneva', country: 'CH', pageviews: 678 },
|
||||
{ city: 'Berlin', country: 'DE', pageviews: 567 },
|
||||
{ city: 'Munich', country: 'DE', pageviews: 432 },
|
||||
{ city: 'San Francisco', country: 'US', pageviews: 345 },
|
||||
{ city: 'Paris', country: 'FR', pageviews: 312 },
|
||||
{ city: 'London', country: 'GB', pageviews: 289 },
|
||||
{ city: 'Amsterdam', country: 'NL', pageviews: 234 },
|
||||
{ city: 'Vienna', country: 'AT', pageviews: 198 },
|
||||
{ city: 'New York', country: 'US', pageviews: 178 },
|
||||
]
|
||||
|
||||
const FAKE_REGIONS = [
|
||||
{ region: 'Zurich', country: 'CH', pageviews: 1567 },
|
||||
{ region: 'Geneva', country: 'CH', pageviews: 734 },
|
||||
{ region: 'Bavaria', country: 'DE', pageviews: 523 },
|
||||
{ region: 'Berlin', country: 'DE', pageviews: 489 },
|
||||
{ region: 'California', country: 'US', pageviews: 456 },
|
||||
{ region: 'Ile-de-France', country: 'FR', pageviews: 345 },
|
||||
{ region: 'England', country: 'GB', pageviews: 312 },
|
||||
{ region: 'North Holland', country: 'NL', pageviews: 267 },
|
||||
{ region: 'Bern', country: 'CH', pageviews: 234 },
|
||||
{ region: 'New York', country: 'US', pageviews: 198 },
|
||||
]
|
||||
|
||||
const FAKE_BROWSERS = [
|
||||
{ browser: 'Chrome', pageviews: 5234 },
|
||||
{ browser: 'Firefox', pageviews: 1518 },
|
||||
{ browser: 'Safari', pageviews: 987 },
|
||||
{ browser: 'Edge', pageviews: 456 },
|
||||
{ browser: 'Brave', pageviews: 178 },
|
||||
{ browser: 'Arc', pageviews: 59 },
|
||||
]
|
||||
|
||||
const FAKE_OS = [
|
||||
{ os: 'macOS', pageviews: 3421 },
|
||||
{ os: 'Windows', pageviews: 2567 },
|
||||
{ os: 'Linux', pageviews: 1234 },
|
||||
{ os: 'iOS', pageviews: 756 },
|
||||
{ os: 'Android', pageviews: 454 },
|
||||
]
|
||||
|
||||
const FAKE_DEVICES = [
|
||||
{ device: 'Desktop', pageviews: 5876 },
|
||||
{ device: 'Mobile', pageviews: 1987 },
|
||||
{ device: 'Tablet', pageviews: 569 },
|
||||
]
|
||||
|
||||
const FAKE_SCREEN_RESOLUTIONS = [
|
||||
{ screen_resolution: '1920x1080', pageviews: 2345 },
|
||||
{ screen_resolution: '1440x900', pageviews: 1567 },
|
||||
{ screen_resolution: '2560x1440', pageviews: 1234 },
|
||||
{ screen_resolution: '1366x768', pageviews: 876 },
|
||||
{ screen_resolution: '3840x2160', pageviews: 654 },
|
||||
{ screen_resolution: '1536x864', pageviews: 432 },
|
||||
{ screen_resolution: '390x844', pageviews: 312 },
|
||||
{ screen_resolution: '393x873', pageviews: 234 },
|
||||
]
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────
|
||||
|
||||
export default function DashboardDemo() {
|
||||
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>('hour')
|
||||
const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>('day')
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const dateRange = { start: today, end: today }
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Browser chrome */}
|
||||
<div className="rounded-t-xl border border-white/[0.08] border-b-0">
|
||||
<div className="flex items-center px-4 py-3 bg-neutral-800/80 rounded-t-xl">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500/20" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500/20" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-500/20" />
|
||||
</div>
|
||||
<div className="ml-4 flex-1 max-w-sm h-7 rounded-md bg-neutral-700/50 flex items-center px-3">
|
||||
<span className="text-xs text-neutral-400 font-mono">pulse.ciphera.net/sites/demo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard content */}
|
||||
<div className="relative border border-white/[0.08] border-t-0 rounded-b-xl overflow-hidden max-h-[1000px]">
|
||||
<div className="bg-neutral-950 p-4 sm:p-6">
|
||||
{/* Dashboard header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">Ciphera</h2>
|
||||
<p className="text-sm text-neutral-400">ciphera.net</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-green-500/10 rounded-full border border-green-500/20">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500" />
|
||||
</span>
|
||||
<span className="text-sm font-medium text-green-400">12 current visitors</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-2 rounded-lg bg-neutral-900/80 border border-white/[0.08] text-sm text-neutral-300">
|
||||
Today
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart with stats */}
|
||||
<div className="mb-6">
|
||||
<Chart
|
||||
data={FAKE_DAILY_STATS}
|
||||
stats={FAKE_STATS}
|
||||
prevStats={FAKE_PREV_STATS}
|
||||
interval={todayInterval}
|
||||
dateRange={dateRange}
|
||||
period="today"
|
||||
todayInterval={todayInterval}
|
||||
setTodayInterval={setTodayInterval}
|
||||
multiDayInterval={multiDayInterval}
|
||||
setMultiDayInterval={setMultiDayInterval}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 2-col grid: Pages + Referrers */}
|
||||
<div className="grid gap-6 lg:grid-cols-2 mb-6 [&>*]:min-w-0">
|
||||
<ContentStats
|
||||
topPages={FAKE_TOP_PAGES}
|
||||
entryPages={FAKE_ENTRY_PAGES}
|
||||
exitPages={FAKE_EXIT_PAGES}
|
||||
domain="ciphera.net"
|
||||
collectPagePaths={true}
|
||||
siteId="demo"
|
||||
dateRange={dateRange}
|
||||
onFilter={noop}
|
||||
/>
|
||||
<TopReferrers
|
||||
referrers={FAKE_REFERRERS}
|
||||
collectReferrers={true}
|
||||
siteId="demo"
|
||||
dateRange={dateRange}
|
||||
onFilter={noop}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 2-col grid: Locations + Tech */}
|
||||
<div className="grid gap-6 lg:grid-cols-2 mb-6 [&>*]:min-w-0">
|
||||
<Locations
|
||||
countries={FAKE_COUNTRIES}
|
||||
cities={FAKE_CITIES}
|
||||
regions={FAKE_REGIONS}
|
||||
geoDataLevel="full"
|
||||
siteId="demo"
|
||||
dateRange={dateRange}
|
||||
onFilter={noop}
|
||||
/>
|
||||
<TechSpecs
|
||||
browsers={FAKE_BROWSERS}
|
||||
os={FAKE_OS}
|
||||
devices={FAKE_DEVICES}
|
||||
screenResolutions={FAKE_SCREEN_RESOLUTIONS}
|
||||
collectDeviceInfo={true}
|
||||
collectScreenResolution={true}
|
||||
siteId="demo"
|
||||
dateRange={dateRange}
|
||||
onFilter={noop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom gradient fade */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-40 bg-gradient-to-t from-neutral-950 to-transparent pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
function rand(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
function fmtDuration(seconds: number) {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}m ${s}s`
|
||||
}
|
||||
|
||||
// Generate realistic hourly visitor counts (low at night, peak afternoon)
|
||||
function generateHourlyPattern(): { hour: string; visitors: number; pageviews: number }[] {
|
||||
const base = [
|
||||
12, 8, 5, 4, 3, 4, 8, 18, 35, 52, 64, 72,
|
||||
78, 85, 88, 82, 74, 60, 48, 38, 30, 25, 20, 16,
|
||||
]
|
||||
return base.map((v, i) => ({
|
||||
hour: `${String(i).padStart(2, '0')}:00`,
|
||||
visitors: v + rand(-4, 4),
|
||||
pageviews: Math.round(v * 2.8) + rand(-6, 6),
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Static panel data ────────────────────────────────────────
|
||||
|
||||
const topPages = [
|
||||
{ label: '/blog/privacy', pct: 85 },
|
||||
{ label: '/pricing', pct: 65 },
|
||||
{ label: '/docs', pct: 45 },
|
||||
{ label: '/about', pct: 30 },
|
||||
{ label: '/integrations', pct: 20 },
|
||||
]
|
||||
|
||||
const topReferrers = [
|
||||
{ label: 'Google', pct: 40 },
|
||||
{ label: 'Direct', pct: 25 },
|
||||
{ label: 'Twitter', pct: 15 },
|
||||
{ label: 'GitHub', pct: 12 },
|
||||
{ label: 'Reddit', pct: 8 },
|
||||
]
|
||||
|
||||
const locations = [
|
||||
{ flag: '\u{1F1E8}\u{1F1ED}', name: 'Switzerland', pct: 30 },
|
||||
{ flag: '\u{1F1E9}\u{1F1EA}', name: 'Germany', pct: 22 },
|
||||
{ flag: '\u{1F1FA}\u{1F1F8}', name: 'USA', pct: 18 },
|
||||
{ flag: '\u{1F1EB}\u{1F1F7}', name: 'France', pct: 15 },
|
||||
{ flag: '\u{1F1EC}\u{1F1E7}', name: 'UK', pct: 15 },
|
||||
]
|
||||
|
||||
const technology = [
|
||||
{ label: 'Chrome', pct: 62 },
|
||||
{ label: 'Firefox', pct: 18 },
|
||||
{ label: 'Safari', pct: 15 },
|
||||
{ label: 'Edge', pct: 5 },
|
||||
]
|
||||
|
||||
const campaigns = [
|
||||
{ label: 'newsletter', pct: 45 },
|
||||
{ label: 'twitter', pct: 30 },
|
||||
{ label: 'producthunt', pct: 25 },
|
||||
]
|
||||
|
||||
// Generate heatmap data: 7 rows (Mon-Sun) x 24 cols (hours)
|
||||
function generateHeatmap(): number[][] {
|
||||
return Array.from({ length: 7 }, (_, day) =>
|
||||
Array.from({ length: 24 }, (_, hour) => {
|
||||
const isWeekend = day >= 5
|
||||
const isNight = hour >= 1 && hour <= 5
|
||||
const isPeak = hour >= 9 && hour <= 17
|
||||
const isMorning = hour >= 7 && hour <= 9
|
||||
const isEvening = hour >= 17 && hour <= 21
|
||||
|
||||
if (isNight) return rand(0, 1)
|
||||
if (isWeekend) {
|
||||
if (isPeak) return rand(2, 4)
|
||||
return rand(1, 3)
|
||||
}
|
||||
if (isPeak) return rand(5, 8)
|
||||
if (isMorning || isEvening) return rand(3, 5)
|
||||
return rand(1, 3)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function heatmapOpacity(value: number): string {
|
||||
if (value <= 1) return 'bg-brand-orange/[0.05]'
|
||||
if (value <= 3) return 'bg-brand-orange/[0.2]'
|
||||
if (value <= 5) return 'bg-brand-orange/[0.5]'
|
||||
return 'bg-brand-orange/[0.8]'
|
||||
}
|
||||
|
||||
// ── SVG chart helpers ────────────────────────────────────────
|
||||
|
||||
function buildSmoothPath(
|
||||
points: { x: number; y: number }[],
|
||||
close: boolean
|
||||
): string {
|
||||
if (points.length < 2) return ''
|
||||
const d: string[] = [`M ${points[0].x},${points[0].y}`]
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = points[Math.max(i - 1, 0)]
|
||||
const p1 = points[i]
|
||||
const p2 = points[i + 1]
|
||||
const p3 = points[Math.min(i + 2, points.length - 1)]
|
||||
|
||||
const cp1x = p1.x + (p2.x - p0.x) / 6
|
||||
const cp1y = p1.y + (p2.y - p0.y) / 6
|
||||
const cp2x = p2.x - (p3.x - p1.x) / 6
|
||||
const cp2y = p2.y - (p3.y - p1.y) / 6
|
||||
|
||||
d.push(`C ${cp1x},${cp1y} ${cp2x},${cp2y} ${p2.x},${p2.y}`)
|
||||
}
|
||||
|
||||
if (close) {
|
||||
const last = points[points.length - 1]
|
||||
const first = points[0]
|
||||
d.push(`L ${last.x},200 L ${first.x},200 Z`)
|
||||
}
|
||||
|
||||
return d.join(' ')
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────
|
||||
|
||||
export default function LiveDemo() {
|
||||
const [visitors, setVisitors] = useState(2847)
|
||||
const [pageviews, setPageviews] = useState(8432)
|
||||
const [bounceRate, setBounceRate] = useState(42)
|
||||
const [avgDuration, setAvgDuration] = useState(154)
|
||||
const [realtimeVisitors, setRealtimeVisitors] = useState(12)
|
||||
const [chartData, setChartData] = useState(generateHourlyPattern)
|
||||
const heatmap = useRef(generateHeatmap())
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setVisitors((v) => v + rand(1, 3))
|
||||
setPageviews((v) => v + rand(2, 5))
|
||||
setBounceRate(() => 38 + rand(0, 7))
|
||||
setAvgDuration(() => 130 + rand(0, 90))
|
||||
setRealtimeVisitors(() => 8 + rand(0, 7))
|
||||
setChartData((prev) => {
|
||||
const next = [...prev]
|
||||
const lastHourNum =
|
||||
parseInt(next[next.length - 1].hour.split(':')[0], 10)
|
||||
const newHour = (lastHourNum + 1) % 24
|
||||
next.push({
|
||||
hour: `${String(newHour).padStart(2, '0')}:00`,
|
||||
visitors: rand(20, 90),
|
||||
pageviews: rand(50, 250),
|
||||
})
|
||||
if (next.length > 24) next.shift()
|
||||
return next
|
||||
})
|
||||
}, 2500)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
// ── Chart SVG ──────────────────────────────────────────────
|
||||
|
||||
const chartW = 800
|
||||
const chartH = 200
|
||||
const maxVisitors = Math.max(...chartData.map((d) => d.visitors), 1)
|
||||
|
||||
const chartPoints = chartData.map((d, i) => ({
|
||||
x: (i / (chartData.length - 1)) * chartW,
|
||||
y: chartH - (d.visitors / maxVisitors) * (chartH - 20) - 10,
|
||||
}))
|
||||
|
||||
const linePath = buildSmoothPath(chartPoints, false)
|
||||
const areaPath = buildSmoothPath(chartPoints, true)
|
||||
|
||||
// ── Stats config ───────────────────────────────────────────
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Visitors',
|
||||
value: visitors.toLocaleString(),
|
||||
change: '+12%',
|
||||
positive: true,
|
||||
},
|
||||
{
|
||||
label: 'Pageviews',
|
||||
value: pageviews.toLocaleString(),
|
||||
change: '+8%',
|
||||
positive: true,
|
||||
},
|
||||
{
|
||||
label: 'Bounce Rate',
|
||||
value: `${bounceRate}%`,
|
||||
change: '-3%',
|
||||
positive: false,
|
||||
},
|
||||
{
|
||||
label: 'Avg. Duration',
|
||||
value: fmtDuration(avgDuration),
|
||||
change: '+15%',
|
||||
positive: true,
|
||||
},
|
||||
]
|
||||
|
||||
const xLabels = chartData
|
||||
.map((d, i) => ({ label: d.hour, i }))
|
||||
.filter((d) => parseInt(d.label.split(':')[0], 10) % 4 === 0)
|
||||
|
||||
const dayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="w-full max-h-[900px] overflow-hidden relative rounded-xl shadow-2xl">
|
||||
{/* Browser chrome */}
|
||||
<div className="bg-neutral-800 border-b border-neutral-800 px-4 py-3 flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-3 h-3 rounded-full bg-red-500/20" />
|
||||
<span className="w-3 h-3 rounded-full bg-yellow-500/20" />
|
||||
<span className="w-3 h-3 rounded-full bg-green-500/20" />
|
||||
</div>
|
||||
<div className="flex-1 bg-neutral-900/80 rounded-md px-3 py-1 text-xs text-neutral-500 text-center">
|
||||
pulse.ciphera.net/sites/demo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard body */}
|
||||
<div className="bg-neutral-950 px-6 py-5 space-y-5">
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-white font-bold text-lg">Ciphera</span>
|
||||
<span className="text-neutral-500 text-sm">ciphera.net</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-neutral-400 ml-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
{realtimeVisitors} current visitors
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-400 bg-neutral-800 px-3 py-1 rounded-full border border-white/[0.08]">
|
||||
Today
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{stats.map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="bg-neutral-900/80 border border-white/[0.08] rounded-xl p-4"
|
||||
>
|
||||
<div className="text-xs text-neutral-500 uppercase tracking-wider mb-1">
|
||||
{s.label}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{s.value}</div>
|
||||
<div
|
||||
className={`text-xs mt-1 ${
|
||||
s.positive ? 'text-green-400' : 'text-orange-400'
|
||||
}`}
|
||||
>
|
||||
{s.positive ? '\u2191' : '\u2193'}
|
||||
{s.change.replace(/[+-]/, '')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="bg-neutral-900/80 border border-white/[0.08] rounded-2xl p-6">
|
||||
<svg
|
||||
viewBox={`0 0 ${chartW} ${chartH + 30}`}
|
||||
className="w-full"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="chartGrad"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="0%" stopColor="#FD5E0F" stopOpacity="0.4" />
|
||||
<stop offset="100%" stopColor="#FD5E0F" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={areaPath} fill="url(#chartGrad)" />
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke="#FD5E0F"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
{/* X-axis labels */}
|
||||
{xLabels.map(({ label, i }) => (
|
||||
<text
|
||||
key={label + i}
|
||||
x={(i / (chartData.length - 1)) * chartW}
|
||||
y={chartH + 22}
|
||||
fill="#525252"
|
||||
fontSize="11"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Two-column panels */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{/* Top Pages */}
|
||||
<PanelCard title="Top Pages" items={topPages} />
|
||||
|
||||
{/* Top Referrers */}
|
||||
<PanelCard title="Top Referrers" items={topReferrers} />
|
||||
|
||||
{/* Locations */}
|
||||
<div className="bg-neutral-900/80 border border-white/[0.08] rounded-2xl p-6">
|
||||
<h3 className="text-sm font-semibold text-neutral-400 uppercase tracking-wider mb-4">
|
||||
Locations
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{locations.map((loc) => (
|
||||
<div key={loc.name}>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="text-lg">{loc.flag}</span>
|
||||
<span className="text-neutral-300 text-sm flex-1">
|
||||
{loc.name}
|
||||
</span>
|
||||
<span className="text-neutral-500 text-sm">
|
||||
{loc.pct}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-neutral-800 rounded-full overflow-hidden ml-8">
|
||||
<div
|
||||
className="h-full bg-brand-orange rounded-full"
|
||||
style={{ width: `${loc.pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technology */}
|
||||
<PanelCard title="Technology" items={technology} />
|
||||
|
||||
{/* Campaigns */}
|
||||
<PanelCard title="Campaigns" items={campaigns} />
|
||||
|
||||
{/* Peak Hours */}
|
||||
<div className="bg-neutral-900/80 border border-white/[0.08] rounded-2xl p-6">
|
||||
<h3 className="text-sm font-semibold text-neutral-400 uppercase tracking-wider mb-4">
|
||||
Peak Hours
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{heatmap.current.map((row, dayIdx) => (
|
||||
<div key={dayIdx} className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-neutral-500 w-6 shrink-0">
|
||||
{dayLabels[dayIdx]}
|
||||
</span>
|
||||
<div className="flex gap-[2px] flex-1">
|
||||
{row.map((val, hourIdx) => (
|
||||
<div
|
||||
key={hourIdx}
|
||||
className={`w-3 h-3 rounded-sm ${heatmapOpacity(val)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom gradient fade */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-neutral-950 to-transparent pointer-events-none" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Panel Card sub-component ─────────────────────────────────
|
||||
|
||||
function PanelCard({
|
||||
title,
|
||||
items,
|
||||
}: {
|
||||
title: string
|
||||
items: { label: string; pct: number }[]
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-neutral-900/80 border border-white/[0.08] rounded-2xl p-6">
|
||||
<h3 className="text-sm font-semibold text-neutral-400 uppercase tracking-wider mb-4">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<div key={item.label}>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-neutral-300">{item.label}</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user