fix: improve landing page
This commit is contained in:
37
apps/public/src/components/eu-flag.tsx
Normal file
37
apps/public/src/components/eu-flag.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
function star(cx: number, cy: number, outerR: number, innerR: number) {
|
||||
const pts: string[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const r = i % 2 === 0 ? outerR : innerR;
|
||||
const angle = (i * Math.PI) / 5 - Math.PI / 2;
|
||||
pts.push(`${cx + r * Math.cos(angle)},${cy + r * Math.sin(angle)}`);
|
||||
}
|
||||
return pts.join(' ');
|
||||
}
|
||||
|
||||
const STARS = Array.from({ length: 12 }, (_, i) => {
|
||||
const angle = (i * 30 - 90) * (Math.PI / 180);
|
||||
return {
|
||||
x: 12 + 5 * Math.cos(angle),
|
||||
y: 8 + 5 * Math.sin(angle),
|
||||
};
|
||||
});
|
||||
|
||||
export function EuFlag({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect fill="#003399" height="16" rx="1.5" width="24" />
|
||||
{STARS.map((s, i) => (
|
||||
<polygon
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static data
|
||||
key={i}
|
||||
fill="#FFCC00"
|
||||
points={star(s.x, s.y, 1.1, 0.45)}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
71
apps/public/src/components/illustrations/conversions.tsx
Normal file
71
apps/public/src/components/illustrations/conversions.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { SimpleChart } from '@/components/simple-chart';
|
||||
|
||||
const variantA = [28, 31, 29, 34, 32, 36, 35, 38, 37, 40, 39, 42];
|
||||
const variantB = [28, 30, 32, 35, 38, 37, 40, 42, 44, 43, 47, 50];
|
||||
|
||||
export function ConversionsIllustration() {
|
||||
return (
|
||||
<div className="h-full col gap-3 px-4 pb-3 pt-5">
|
||||
{/* A/B variant cards */}
|
||||
<div className="row gap-3">
|
||||
<div className="col flex-1 gap-1 rounded-xl border bg-card p-3 transition-all duration-300 group-hover:-translate-y-0.5">
|
||||
<div className="row items-center gap-1.5">
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">
|
||||
Variant A
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-bold font-mono text-xl">28.4%</span>
|
||||
<SimpleChart
|
||||
height={24}
|
||||
points={variantA}
|
||||
strokeColor="var(--foreground)"
|
||||
width={200}
|
||||
/>
|
||||
</div>
|
||||
<div className="col flex-1 gap-1 rounded-xl border border-emerald-500/30 bg-card p-3 transition-all delay-75 duration-300 group-hover:-translate-y-0.5">
|
||||
<div className="row items-center gap-1.5">
|
||||
<span className="rounded bg-emerald-500/10 px-1.5 py-0.5 font-mono text-[9px] text-emerald-600 dark:text-emerald-400">
|
||||
Variant B ↑
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-bold font-mono text-xl text-emerald-500">
|
||||
41.2%
|
||||
</span>
|
||||
<SimpleChart
|
||||
height={24}
|
||||
points={variantB}
|
||||
strokeColor="rgb(34, 197, 94)"
|
||||
width={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breakdown label */}
|
||||
<div className="col gap-1 rounded-xl border bg-card/60 px-3 py-2.5">
|
||||
<span className="text-[9px] uppercase tracking-wider text-muted-foreground">
|
||||
Breakdown by experiment variant
|
||||
</span>
|
||||
<div className="row items-center gap-2">
|
||||
<div className="h-1 flex-1 rounded-full bg-muted">
|
||||
<div
|
||||
className="h-1 rounded-full bg-foreground/50"
|
||||
style={{ width: '57%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[9px] text-muted-foreground">A: 57%</span>
|
||||
</div>
|
||||
<div className="row items-center gap-2">
|
||||
<div className="h-1 flex-1 rounded-full bg-muted">
|
||||
<div
|
||||
className="h-1 rounded-full bg-emerald-500"
|
||||
style={{ width: '82%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[9px] text-muted-foreground">B: 82%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
const queries = [
|
||||
{
|
||||
query: 'openpanel analytics',
|
||||
clicks: 312,
|
||||
impressions: '4.1k',
|
||||
pos: 1.2,
|
||||
},
|
||||
{
|
||||
query: 'open source mixpanel alternative',
|
||||
clicks: 187,
|
||||
impressions: '3.8k',
|
||||
pos: 2.4,
|
||||
},
|
||||
{
|
||||
query: 'web analytics without cookies',
|
||||
clicks: 98,
|
||||
impressions: '2.2k',
|
||||
pos: 4.7,
|
||||
},
|
||||
];
|
||||
|
||||
export function GoogleSearchConsoleIllustration() {
|
||||
return (
|
||||
<div className="col h-full gap-2 px-4 pt-5 pb-3">
|
||||
{/* Top stats */}
|
||||
<div className="row mb-1 gap-2">
|
||||
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
|
||||
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
|
||||
Clicks
|
||||
</span>
|
||||
<span className="font-bold font-mono text-sm">740</span>
|
||||
</div>
|
||||
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
|
||||
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
|
||||
Impr.
|
||||
</span>
|
||||
<span className="font-bold font-mono text-sm">13k</span>
|
||||
</div>
|
||||
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
|
||||
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
|
||||
Avg. CTR
|
||||
</span>
|
||||
<span className="font-bold font-mono text-sm">5.7%</span>
|
||||
</div>
|
||||
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
|
||||
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
|
||||
Avg. Pos
|
||||
</span>
|
||||
<span className="font-bold font-mono text-sm">2.8</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Query table */}
|
||||
<div className="flex-1 overflow-hidden rounded-xl border border-border bg-card">
|
||||
<div className="row border-border border-b px-3 py-1.5">
|
||||
<span className="flex-1 text-[8px] text-muted-foreground uppercase tracking-wider">
|
||||
Query
|
||||
</span>
|
||||
<span className="w-10 text-right text-[8px] text-muted-foreground uppercase tracking-wider">
|
||||
Pos
|
||||
</span>
|
||||
</div>
|
||||
{queries.map((q, i) => (
|
||||
<div
|
||||
className="row items-center border-border/50 border-b px-3 py-1.5 last:border-0"
|
||||
key={q.query}
|
||||
style={{ opacity: 1 - i * 0.18 }}
|
||||
>
|
||||
<span className="flex-1 truncate text-[9px]">{q.query}</span>
|
||||
<span className="w-10 text-right font-mono text-[9px] text-muted-foreground">
|
||||
{q.pos}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
apps/public/src/components/illustrations/notifications.tsx
Normal file
47
apps/public/src/components/illustrations/notifications.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { CheckCircleIcon } from 'lucide-react';
|
||||
|
||||
export function NotificationsIllustration() {
|
||||
return (
|
||||
<div className="col h-full justify-center gap-3 px-6 py-4">
|
||||
{/* Funnel completion notification */}
|
||||
<div className="col gap-2 rounded-xl border border-border bg-card p-4 shadow-lg transition-transform duration-300 group-hover:-translate-y-0.5">
|
||||
<div className="row items-center gap-2">
|
||||
<CheckCircleIcon className="size-4 shrink-0 text-emerald-500" />
|
||||
<span className="font-semibold text-xs">Funnel completed</span>
|
||||
<span className="ml-auto text-[9px] text-muted-foreground">
|
||||
just now
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-medium text-sm">Signup Flow — 142 today</p>
|
||||
<div className="row items-center gap-2">
|
||||
<div className="h-1.5 flex-1 rounded-full bg-muted">
|
||||
<div
|
||||
className="h-1.5 rounded-full bg-emerald-500"
|
||||
style={{ width: '71%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
71% conversion
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification rule */}
|
||||
<div className="col gap-1.5 px-3 opacity-80">
|
||||
<span className="text-[9px] text-muted-foreground uppercase tracking-wider">
|
||||
Notification rule
|
||||
</span>
|
||||
<div className="row flex-wrap items-center gap-1.5">
|
||||
<span className="text-[9px] text-muted-foreground">When</span>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">
|
||||
Signup Flow
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground">completes →</span>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">
|
||||
#growth
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
apps/public/src/components/illustrations/retention.tsx
Normal file
63
apps/public/src/components/illustrations/retention.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
const cohorts = [
|
||||
{ label: 'Week 1', values: [100, 68, 45, 38, 31] },
|
||||
{ label: 'Week 2', values: [100, 72, 51, 42, 35] },
|
||||
{ label: 'Week 3', values: [100, 65, 48, 39, null] },
|
||||
{ label: 'Week 4', values: [100, 70, null, null, null] },
|
||||
];
|
||||
|
||||
const headers = ['Day 0', 'Day 1', 'Day 7', 'Day 14', 'Day 30'];
|
||||
|
||||
function cellStyle(v: number | null) {
|
||||
if (v === null) {
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--muted-foreground)',
|
||||
};
|
||||
}
|
||||
const opacity = 0.12 + (v / 100) * 0.7;
|
||||
return {
|
||||
backgroundColor: `rgba(34, 197, 94, ${opacity})`,
|
||||
borderColor: `rgba(34, 197, 94, 0.3)`,
|
||||
color: v > 55 ? 'rgba(0,0,0,0.75)' : 'var(--foreground)',
|
||||
};
|
||||
}
|
||||
|
||||
export function RetentionIllustration() {
|
||||
return (
|
||||
<div className="h-full px-4 pb-3 pt-5">
|
||||
<div className="col h-full gap-1.5">
|
||||
<div className="row gap-1">
|
||||
<div className="w-12 shrink-0" />
|
||||
{headers.map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className="flex-1 text-center text-[9px] text-muted-foreground"
|
||||
>
|
||||
{h}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{cohorts.map(({ label, values }) => (
|
||||
<div key={label} className="row flex-1 gap-1">
|
||||
<div className="flex w-12 shrink-0 items-center text-[9px] text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
{values.map((v, i) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static data
|
||||
key={i}
|
||||
className="flex flex-1 items-center justify-center rounded border text-[9px] font-medium transition-all duration-300 group-hover:scale-[1.03]"
|
||||
style={cellStyle(v)}
|
||||
>
|
||||
{v !== null ? `${v}%` : '—'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
apps/public/src/components/illustrations/revenue.tsx
Normal file
72
apps/public/src/components/illustrations/revenue.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { SimpleChart } from '@/components/simple-chart';
|
||||
|
||||
const revenuePoints = [28, 34, 31, 40, 37, 44, 41, 50, 47, 56, 59, 65];
|
||||
|
||||
const referrers = [
|
||||
{ name: 'google.com', amount: '$3,840', pct: 46 },
|
||||
{ name: 'twitter.com', amount: '$1,920', pct: 23 },
|
||||
{ name: 'github.com', amount: '$1,260', pct: 15 },
|
||||
{ name: 'direct', amount: '$1,400', pct: 16 },
|
||||
];
|
||||
|
||||
export function RevenueIllustration() {
|
||||
return (
|
||||
<div className="h-full col gap-3 px-4 pb-3 pt-5">
|
||||
{/* MRR stat + chart */}
|
||||
<div className="row gap-3">
|
||||
<div className="col gap-1 rounded-xl border bg-card p-3 transition-all duration-300 group-hover:-translate-y-0.5">
|
||||
<span className="text-[9px] uppercase tracking-wider text-muted-foreground">
|
||||
MRR
|
||||
</span>
|
||||
<span className="font-bold font-mono text-xl text-emerald-500">
|
||||
$8,420
|
||||
</span>
|
||||
<span className="text-[9px] text-emerald-500">↑ 12% this month</span>
|
||||
</div>
|
||||
<div className="col flex-1 gap-1 rounded-xl border bg-card px-3 py-2">
|
||||
<span className="text-[9px] text-muted-foreground">MRR over time</span>
|
||||
<SimpleChart
|
||||
className="mt-1 flex-1"
|
||||
height={36}
|
||||
points={revenuePoints}
|
||||
strokeColor="rgb(34, 197, 94)"
|
||||
width={400}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revenue by referrer */}
|
||||
<div className="flex-1 overflow-hidden rounded-xl border bg-card">
|
||||
<div className="row border-b border-border px-3 py-1.5">
|
||||
<span className="flex-1 text-[8px] uppercase tracking-wider text-muted-foreground">
|
||||
Referrer
|
||||
</span>
|
||||
<span className="text-[8px] uppercase tracking-wider text-muted-foreground">
|
||||
Revenue
|
||||
</span>
|
||||
</div>
|
||||
{referrers.map((r) => (
|
||||
<div
|
||||
className="row items-center gap-2 border-b border-border/50 px-3 py-1.5 last:border-0"
|
||||
key={r.name}
|
||||
>
|
||||
<span className="text-[9px] text-muted-foreground flex-none w-20 truncate">
|
||||
{r.name}
|
||||
</span>
|
||||
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-1 rounded-full bg-emerald-500/70"
|
||||
style={{ width: `${r.pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono text-[9px] text-emerald-500 flex-none">
|
||||
{r.amount}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
apps/public/src/components/illustrations/session-replay.tsx
Normal file
89
apps/public/src/components/illustrations/session-replay.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { PlayIcon } from 'lucide-react';
|
||||
|
||||
export function SessionReplayIllustration() {
|
||||
return (
|
||||
<div className="h-full px-6 pb-3 pt-4">
|
||||
<div className="col h-full overflow-hidden rounded-xl border border-border bg-background shadow-lg transition-transform duration-300 group-hover:-translate-y-0.5">
|
||||
{/* Browser chrome */}
|
||||
<div className="row shrink-0 items-center gap-1.5 border-b border-border bg-muted/30 px-3 py-2">
|
||||
<div className="h-2 w-2 rounded-full bg-red-400" />
|
||||
<div className="h-2 w-2 rounded-full bg-yellow-400" />
|
||||
<div className="h-2 w-2 rounded-full bg-green-400" />
|
||||
<div className="mx-2 flex-1 rounded bg-background/80 px-2 py-0.5 text-[8px] text-muted-foreground">
|
||||
app.example.com/pricing
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
<div className="relative flex-1 overflow-hidden p-3">
|
||||
<div className="mb-2 h-2 w-20 rounded-full bg-muted/60" />
|
||||
<div className="mb-4 h-2 w-32 rounded-full bg-muted/40" />
|
||||
<div className="row mb-3 gap-2">
|
||||
<div className="h-10 flex-1 rounded-lg border border-border bg-muted/20" />
|
||||
<div className="h-10 flex-1 rounded-lg border border-border bg-muted/20" />
|
||||
</div>
|
||||
<div className="mb-2 h-2 w-28 rounded-full bg-muted/30" />
|
||||
<div className="h-2 w-24 rounded-full bg-muted/20" />
|
||||
|
||||
{/* Click heatspot */}
|
||||
<div
|
||||
className="absolute"
|
||||
style={{ left: '62%', top: '48%' }}
|
||||
>
|
||||
<div className="h-4 w-4 animate-pulse rounded-full border-2 border-blue-500/70 bg-blue-500/20" />
|
||||
</div>
|
||||
<div
|
||||
className="absolute"
|
||||
style={{ left: '25%', top: '32%' }}
|
||||
>
|
||||
<div className="h-2.5 w-2.5 rounded-full border border-blue-500/40 bg-blue-500/25" />
|
||||
</div>
|
||||
|
||||
{/* Cursor trail */}
|
||||
<svg
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
style={{ overflow: 'visible' }}
|
||||
>
|
||||
<path
|
||||
d="M 18% 22% Q 42% 28% 62% 48%"
|
||||
fill="none"
|
||||
stroke="rgb(59 130 246 / 0.35)"
|
||||
strokeDasharray="3 2"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Cursor */}
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
left: 'calc(62% + 8px)',
|
||||
top: 'calc(48% + 6px)',
|
||||
}}
|
||||
>
|
||||
<svg fill="none" height="12" viewBox="0 0 10 12" width="10">
|
||||
<path
|
||||
d="M0 0L0 10L3 7L5 11L6.5 10.5L4.5 6.5L8 6L0 0Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playback bar */}
|
||||
<div className="row shrink-0 items-center gap-2 border-t border-border bg-muted/20 px-3 py-2">
|
||||
<PlayIcon className="size-3 shrink-0 text-muted-foreground" />
|
||||
<div className="relative flex-1 h-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="absolute left-0 top-0 h-1 rounded-full bg-blue-500"
|
||||
style={{ width: '42%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono text-[8px] text-muted-foreground">
|
||||
0:52 / 2:05
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,188 +1,165 @@
|
||||
'use client';
|
||||
import { SimpleChart } from '@/components/simple-chart';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import NumberFlow from '@number-flow/react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ArrowUpIcon } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const TRAFFIC_SOURCES = [
|
||||
const VISITOR_DATA = [1840, 2100, 1950, 2400, 2250, 2650, 2980];
|
||||
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
const STATS = [
|
||||
{ label: 'Visitors', value: 4128, formatted: null, change: 12, up: true },
|
||||
{ label: 'Page views', value: 12438, formatted: '12.4k', change: 8, up: true },
|
||||
{ label: 'Bounce rate', value: null, formatted: '42%', change: 3, up: false },
|
||||
{ label: 'Avg. session', value: null, formatted: '3m 23s', change: 5, up: true },
|
||||
];
|
||||
|
||||
const SOURCES = [
|
||||
{
|
||||
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Fgoogle.com',
|
||||
name: 'Google',
|
||||
percentage: 49,
|
||||
value: 2039,
|
||||
},
|
||||
{
|
||||
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Finstagram.com',
|
||||
name: 'Instagram',
|
||||
percentage: 23,
|
||||
value: 920,
|
||||
},
|
||||
{
|
||||
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Ffacebook.com',
|
||||
name: 'Facebook',
|
||||
percentage: 18,
|
||||
value: 750,
|
||||
name: 'google.com',
|
||||
pct: 49,
|
||||
},
|
||||
{
|
||||
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Ftwitter.com',
|
||||
name: 'Twitter',
|
||||
percentage: 10,
|
||||
value: 412,
|
||||
name: 'twitter.com',
|
||||
pct: 21,
|
||||
},
|
||||
{
|
||||
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Fgithub.com',
|
||||
name: 'github.com',
|
||||
pct: 14,
|
||||
},
|
||||
];
|
||||
|
||||
const COUNTRIES = [
|
||||
{ icon: '🇺🇸', name: 'United States', percentage: 37, value: 1842 },
|
||||
{ icon: '🇩🇪', name: 'Germany', percentage: 28, value: 1391 },
|
||||
{ icon: '🇬🇧', name: 'United Kingdom', percentage: 20, value: 982 },
|
||||
{ icon: '🇯🇵', name: 'Japan', percentage: 15, value: 751 },
|
||||
];
|
||||
function AreaChart({ data }: { data: number[] }) {
|
||||
const max = Math.max(...data);
|
||||
const w = 400;
|
||||
const h = 64;
|
||||
const xStep = w / (data.length - 1);
|
||||
const pts = data.map((v, i) => ({ x: i * xStep, y: h - (v / max) * h }));
|
||||
const line = pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x},${p.y}`).join(' ');
|
||||
const area = `${line} L ${w},${h} L 0,${h} Z`;
|
||||
const last = pts[pts.length - 1];
|
||||
|
||||
return (
|
||||
<svg className="w-full" viewBox={`0 0 ${w} ${h + 4}`}>
|
||||
<defs>
|
||||
<linearGradient id="wa-fill" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="rgb(59 130 246)" stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor="rgb(59 130 246)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={area} fill="url(#wa-fill)" />
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke="rgb(59 130 246)"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<circle cx={last.x} cy={last.y} fill="rgb(59 130 246)" r="3" />
|
||||
<circle
|
||||
cx={last.x}
|
||||
cy={last.y}
|
||||
fill="none"
|
||||
r="6"
|
||||
stroke="rgb(59 130 246)"
|
||||
strokeOpacity="0.3"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function WebAnalyticsIllustration() {
|
||||
const [currentSourceIndex, setCurrentSourceIndex] = useState(0);
|
||||
const [liveVisitors, setLiveVisitors] = useState(47);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentSourceIndex((prev) => (prev + 1) % TRAFFIC_SOURCES.length);
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
const values = [47, 51, 44, 53, 49, 56];
|
||||
let i = 0;
|
||||
const id = setInterval(() => {
|
||||
i = (i + 1) % values.length;
|
||||
setLiveVisitors(values[i]);
|
||||
}, 2500);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="px-12 group aspect-video">
|
||||
<div className="relative h-full col">
|
||||
<MetricCard
|
||||
title="Session duration"
|
||||
value="3m 23s"
|
||||
change="3%"
|
||||
chartPoints={[40, 10, 20, 43, 20, 40, 30, 37, 40, 34, 50, 31]}
|
||||
color="var(--foreground)"
|
||||
className="absolute w-full rotate-0 top-2 left-2 group-hover:-translate-y-1 group-hover:-rotate-2 transition-all duration-300"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Bounce rate"
|
||||
value="46%"
|
||||
change="3%"
|
||||
chartPoints={[10, 46, 20, 43, 20, 40, 30, 37, 40, 34, 50, 31]}
|
||||
color="var(--foreground)"
|
||||
className="absolute w-full -rotate-2 -left-2 top-12 group-hover:-translate-y-1 group-hover:rotate-0 transition-all duration-300"
|
||||
/>
|
||||
<div className="col gap-4 w-[80%] md:w-[70%] ml-auto mt-auto">
|
||||
<BarCell
|
||||
{...TRAFFIC_SOURCES[currentSourceIndex]}
|
||||
className="group-hover:scale-105 transition-all duration-300"
|
||||
/>
|
||||
<BarCell
|
||||
{...TRAFFIC_SOURCES[
|
||||
(currentSourceIndex + 1) % TRAFFIC_SOURCES.length
|
||||
]}
|
||||
className="group-hover:scale-105 transition-all duration-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
chartPoints,
|
||||
color,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
change: string;
|
||||
chartPoints: number[];
|
||||
color: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('col bg-card rounded-lg p-4 pb-6 border', className)}>
|
||||
<div className="row items-end justify-between">
|
||||
<div>
|
||||
<div className="text-muted-foreground text-sm">{title}</div>
|
||||
<div className="text-2xl font-semibold font-mono">{value}</div>
|
||||
</div>
|
||||
<div className="row gap-2 items-center font-mono font-medium">
|
||||
<ArrowUpIcon className="size-3" strokeWidth={3} />
|
||||
<div>{change}</div>
|
||||
</div>
|
||||
</div>
|
||||
<SimpleChart
|
||||
width={400}
|
||||
height={30}
|
||||
points={chartPoints}
|
||||
strokeColor={color}
|
||||
className="mt-4"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BarCell({
|
||||
icon,
|
||||
name,
|
||||
percentage,
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
icon: string;
|
||||
name: string;
|
||||
percentage: number;
|
||||
value: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative p-4 py-2 bg-card rounded-lg shadow-[0_10px_30px_rgba(0,0,0,0.3)] border',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="absolute bg-background bottom-0 top-0 left-0 rounded-lg transition-all duration-500"
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="relative row justify-between ">
|
||||
<div className="row gap-2 items-center font-medium text-sm">
|
||||
{icon.startsWith('http') ? (
|
||||
<Image
|
||||
alt="serie icon"
|
||||
className="max-h-4 rounded-[2px] object-contain"
|
||||
src={icon}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-2xl">{icon}</div>
|
||||
)}
|
||||
<AnimatePresence mode="popLayout">
|
||||
<motion.div
|
||||
key={name}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{name}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div className="row gap-3 font-mono text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
<NumberFlow value={percentage} />%
|
||||
<div className="aspect-video col gap-2.5 p-5">
|
||||
{/* Header */}
|
||||
<div className="row items-center justify-between">
|
||||
<div className="row items-center gap-1.5">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
<NumberFlow value={liveVisitors} /> online now
|
||||
</span>
|
||||
<NumberFlow value={value} locales={'en-US'} />
|
||||
</div>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[9px] text-muted-foreground">
|
||||
Last 7 days
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* KPI tiles */}
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{STATS.map((stat) => (
|
||||
<div
|
||||
className="col gap-0.5 rounded-lg border bg-card px-2 py-1.5"
|
||||
key={stat.label}
|
||||
>
|
||||
<span className="text-[8px] text-muted-foreground">{stat.label}</span>
|
||||
<span className="font-mono font-semibold text-xs leading-tight">
|
||||
{stat.formatted ??
|
||||
(stat.value !== null ? (
|
||||
<NumberFlow locales="en-US" value={stat.value} />
|
||||
) : null)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[8px] ${stat.up ? 'text-emerald-500' : 'text-red-400'}`}
|
||||
>
|
||||
{stat.up ? '↑' : '↓'} {stat.change}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Area chart */}
|
||||
<div className="flex-1 col gap-1 overflow-hidden rounded-xl border bg-card px-3 pt-2 pb-1">
|
||||
<span className="text-[8px] text-muted-foreground">Unique visitors</span>
|
||||
<AreaChart data={VISITOR_DATA} />
|
||||
<div className="row justify-between px-0.5">
|
||||
{DAYS.map((d) => (
|
||||
<span className="text-[7px] text-muted-foreground" key={d}>
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traffic sources */}
|
||||
<div className="row gap-1.5">
|
||||
{SOURCES.map((src) => (
|
||||
<div
|
||||
className="row flex-1 items-center gap-1.5 overflow-hidden rounded-lg border bg-card px-2 py-1.5"
|
||||
key={src.name}
|
||||
>
|
||||
<Image
|
||||
alt={src.name}
|
||||
className="rounded-[2px] object-contain"
|
||||
height={10}
|
||||
src={src.icon}
|
||||
width={10}
|
||||
/>
|
||||
<span className="flex-1 truncate text-[9px]">{src.name}</span>
|
||||
<span className="font-mono text-[9px] text-muted-foreground">
|
||||
{src.pct}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
type PerkIcon = LucideIcon | React.ComponentType<{ className?: string }>;
|
||||
|
||||
export function Perks({
|
||||
perks,
|
||||
className,
|
||||
}: { perks: { text: string; icon: LucideIcon }[]; className?: string }) {
|
||||
}: { perks: { text: string; icon: PerkIcon }[]; className?: string }) {
|
||||
return (
|
||||
<ul className={cn('grid grid-cols-2 gap-2', className)}>
|
||||
{perks.map((perk) => (
|
||||
|
||||
Reference in New Issue
Block a user