feature(public,docs): new public website and docs

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-11-13 21:15:46 +01:00
parent fc2a019e1d
commit a022cb4831
234 changed files with 9341 additions and 6154 deletions

View File

@@ -0,0 +1,271 @@
'use client';
import { AnimatePresence, motion } from 'framer-motion';
import {
BellIcon,
BookOpenIcon,
DownloadIcon,
EyeIcon,
HeartIcon,
LogOutIcon,
MessageSquareIcon,
SearchIcon,
SettingsIcon,
Share2Icon,
ShoppingCartIcon,
StarIcon,
ThumbsUpIcon,
UserPlusIcon,
} from 'lucide-react';
import { useEffect, useState } from 'react';
interface Event {
id: number;
action: string;
location: string;
platform: string;
icon: any;
color: string;
}
const locations = [
'Gothenburg',
'Stockholm',
'Oslo',
'Copenhagen',
'Berlin',
'New York',
'Singapore',
'London',
'Paris',
'Madrid',
'Rome',
'Barcelona',
'Amsterdam',
'Vienna',
];
const platforms = ['iOS', 'Android', 'Windows', 'macOS'];
const browsers = ['WebKit', 'Chrome', 'Firefox', 'Safari'];
const getCountryFlag = (country: (typeof locations)[number]) => {
switch (country) {
case 'Gothenburg':
return '🇸🇪';
case 'Stockholm':
return '🇸🇪';
case 'Oslo':
return '🇳🇴';
case 'Copenhagen':
return '🇩🇰';
case 'Berlin':
return '🇩🇪';
case 'New York':
return '🇺🇸';
case 'Singapore':
return '🇸🇬';
case 'London':
return '🇬🇧';
case 'Paris':
return '🇫🇷';
case 'Madrid':
return '🇪🇸';
case 'Rome':
return '🇮🇹';
case 'Barcelona':
return '🇪🇸';
case 'Amsterdam':
return '🇳🇱';
case 'Vienna':
return '🇦🇹';
}
};
const getPlatformIcon = (platform: (typeof platforms)[number]) => {
switch (platform) {
case 'iOS':
return '🍎';
case 'Android':
return '🤖';
case 'Windows':
return '💻';
case 'macOS':
return '🍎';
}
};
const TOTAL_EVENTS = 10;
export function EventsFeature() {
const [events, setEvents] = useState<Event[]>([
{
id: 1730663803358.4075,
action: 'purchase',
location: 'New York',
platform: 'macOS',
icon: ShoppingCartIcon,
color: 'bg-blue-500',
},
{
id: 1730663801358.3079,
action: 'logout',
location: 'Copenhagen',
platform: 'Windows',
icon: LogOutIcon,
color: 'bg-red-500',
},
{
id: 1730663799358.0283,
action: 'sign up',
location: 'Berlin',
platform: 'Android',
icon: UserPlusIcon,
color: 'bg-green-500',
},
{
id: 1730663797357.2036,
action: 'share',
location: 'Barcelona',
platform: 'macOS',
icon: Share2Icon,
color: 'bg-cyan-500',
},
{
id: 1730663795358.763,
action: 'sign up',
location: 'New York',
platform: 'macOS',
icon: UserPlusIcon,
color: 'bg-green-500',
},
{
id: 1730663792067.689,
action: 'share',
location: 'New York',
platform: 'macOS',
icon: Share2Icon,
color: 'bg-cyan-500',
},
{
id: 1730663790075.3435,
action: 'like',
location: 'Copenhagen',
platform: 'iOS',
icon: HeartIcon,
color: 'bg-pink-500',
},
{
id: 1730663788070.351,
action: 'recommend',
location: 'Oslo',
platform: 'Android',
icon: ThumbsUpIcon,
color: 'bg-orange-500',
},
{
id: 1730663786074.429,
action: 'read',
location: 'New York',
platform: 'Windows',
icon: BookOpenIcon,
color: 'bg-teal-500',
},
{
id: 1730663784065.6309,
action: 'sign up',
location: 'Gothenburg',
platform: 'iOS',
icon: UserPlusIcon,
color: 'bg-green-500',
},
]);
useEffect(() => {
// Prepend new event every 2 seconds
const interval = setInterval(() => {
setEvents((prevEvents) => [
generateEvent(),
...prevEvents.slice(0, TOTAL_EVENTS - 1),
]);
}, 2000);
return () => clearInterval(interval);
}, []);
return (
<div className="overflow-hidden p-8 max-h-[700px]">
<div
className="min-w-[1000px] gap-4 flex flex-col overflow-hidden relative isolate"
// style={{ height: 60 * TOTAL_EVENTS + 16 * (TOTAL_EVENTS - 1) }}
>
<AnimatePresence mode="popLayout" initial={false}>
{events.map((event) => (
<motion.div
key={event.id}
className="flex items-center shadow bg-background-light rounded"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: '60px' }}
exit={{ opacity: 0, height: 0 }}
transition={{
duration: 0.3,
type: 'spring',
stiffness: 500,
damping: 50,
opacity: { duration: 0.2 },
}}
>
<div className="flex items-center gap-2 w-[200px] py-2 px-4">
<div
className={`size-8 rounded-full bg-background flex items-center justify-center ${event.color} text-white `}
>
{event.icon && <event.icon size={16} />}
</div>
<span className="font-medium truncate">{event.action}</span>
</div>
<div className="w-[150px] py-2 px-4 truncate">
<span className="mr-2 text-xl relative top-px">
{getCountryFlag(event.location)}
</span>
{event.location}
</div>
<div className="w-[150px] py-2 px-4 truncate">
<img src={getPlatformIcon(event.platform)} alt="" />
{event.platform}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
);
}
// Helper function to generate events (moved outside component)
function generateEvent() {
const actions = [
{ text: 'sign up', icon: UserPlusIcon, color: 'bg-green-500' },
{ text: 'purchase', icon: ShoppingCartIcon, color: 'bg-blue-500' },
{ text: 'screen view', icon: EyeIcon, color: 'bg-purple-500' },
{ text: 'logout', icon: LogOutIcon, color: 'bg-red-500' },
{ text: 'like', icon: HeartIcon, color: 'bg-pink-500' },
{ text: 'comment', icon: MessageSquareIcon, color: 'bg-indigo-500' },
{ text: 'share', icon: Share2Icon, color: 'bg-cyan-500' },
{ text: 'download', icon: DownloadIcon, color: 'bg-emerald-500' },
{ text: 'notification', icon: BellIcon, color: 'bg-violet-500' },
{ text: 'settings', icon: SettingsIcon, color: 'bg-slate-500' },
{ text: 'search', icon: SearchIcon, color: 'bg-violet-500' },
{ text: 'read', icon: BookOpenIcon, color: 'bg-teal-500' },
{ text: 'recommend', icon: ThumbsUpIcon, color: 'bg-orange-500' },
{ text: 'favorite', icon: StarIcon, color: 'bg-yellow-500' },
];
const selectedAction = actions[Math.floor(Math.random() * actions.length)];
return {
id: Date.now() + Math.random(),
action: selectedAction.text,
location: locations[Math.floor(Math.random() * locations.length)],
platform: platforms[Math.floor(Math.random() * platforms.length)],
icon: selectedAction.icon,
color: selectedAction.color,
};
}

View File

@@ -0,0 +1,208 @@
'use client';
import { motion } from 'framer-motion';
import { useEffect, useState } from 'react';
// Mock data structure for retention cohort
const COHORT_DATA = [
{
week: 'Week 1',
users: '2,543',
retention: [100, 84, 73, 67, 62, 58],
},
{
week: 'Week 2',
users: '2,148',
retention: [100, 80, 69, 63, 59, 55],
},
{
week: 'Week 3',
users: '1,958',
retention: [100, 82, 71, 64, 60, 56],
},
{
week: 'Week 4',
users: '2,034',
retention: [100, 83, 72, 65, 61, 57],
},
{
week: 'Week 5',
users: '1,987',
retention: [100, 81, 70, 64, 60, 56],
},
{
week: 'Week 6',
users: '2,245',
retention: [100, 85, 74, 68, 64, 60],
},
{
week: 'Week 7',
users: '2,108',
retention: [100, 82, 71, 65, 61, 57],
},
{
week: 'Week 8',
users: '1,896',
retention: [100, 83, 72, 66, 62, 58],
},
{
week: 'Week 9',
users: '2,156',
retention: [100, 81, 70, 64, 60, 56],
},
{ week: 'Week 10', users: '2,089', retention: [100, 84, 73, 67, 63] },
{ week: 'Week 11', users: '1,967', retention: [100, 82, 71, 65] },
{ week: 'Week 12', users: '2,198', retention: [100, 83, 72] },
{ week: 'Week 13', users: '2,045', retention: [100, 81] },
// { week: 'Week 14', users: '1,978', retention: [100, 84, 73] },
// { week: 'Week 15', users: '2,134', retention: [100, 82] },
// { week: 'Week 16', users: '1,923', retention: [100] },
];
const COHORT_DATA_ALT = [
{
week: 'Week 1',
users: '2,876',
retention: [100, 79, 76, 70, 65, 61],
},
{
week: 'Week 2',
users: '2,543',
retention: [100, 85, 73, 67, 62, 58],
},
{
week: 'Week 3',
users: '2,234',
retention: [100, 79, 75, 68, 63, 59],
},
{
week: 'Week 4',
users: '2,456',
retention: [100, 88, 77, 69, 65, 61],
},
{
week: 'Week 5',
users: '2,321',
retention: [100, 77, 73, 67, 54, 42],
},
{
week: 'Week 6',
users: '2,654',
retention: [100, 91, 83, 69, 66, 62],
},
{
week: 'Week 7',
users: '2,432',
retention: [100, 93, 88, 72, 64, 60],
},
{
week: 'Week 8',
users: '2,123',
retention: [100, 78, 76, 69, 65, 61],
},
{
week: 'Week 9',
users: '2,567',
retention: [100, 70, 64, 61, 59, 58],
},
{ week: 'Week 10', users: '2,345', retention: [100, 88, 77, 71, 67] },
{ week: 'Week 11', users: '2,234', retention: [100, 86, 75, 69] },
{ week: 'Week 12', users: '2,543', retention: [100, 79, 76] },
{ week: 'Week 13', users: '2,321', retention: [100, 77] },
// { week: 'Week 14', users: '1,978', retention: [100, 84, 73] },
// { week: 'Week 15', users: '2,134', retention: [100, 82] },
// { week: 'Week 16', users: '1,923', retention: [100] },
];
function RetentionCell({ percentage }: { percentage: number }) {
// Calculate color intensity based on percentage
const getBackgroundColor = (value: number) => {
if (value === 0) return 'bg-transparent';
// Using CSS color mixing to create a gradient from light to dark blue
return `rgb(${Math.round(239 - value * 1.39)} ${Math.round(246 - value * 1.46)} ${Math.round(255 - value * 0.55)})`;
};
return (
<div className="flex items-center justify-center p-px text-sm font-medium w-[80px]">
<div
className="flex text-white items-center justify-center w-full h-full rounded"
style={{
backgroundColor: getBackgroundColor(percentage),
}}
>
<motion.span
key={percentage}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{percentage}%
</motion.span>
</div>
</div>
);
}
export function ProductAnalyticsFeature() {
const [currentData, setCurrentData] = useState(COHORT_DATA);
useEffect(() => {
const interval = setInterval(() => {
setCurrentData((current) =>
current === COHORT_DATA ? COHORT_DATA_ALT : COHORT_DATA,
);
}, 3000);
return () => clearInterval(interval);
}, []);
return (
<div className="p-4 w-full overflow-hidden">
<div className="flex">
{/* Header row */}
<div className="min-w-[70px] flex flex-col">
<div className="p-2 font-medium text-xs text-muted-foreground">
Cohort
</div>
</div>
{/* Week numbers - changed length to 6 */}
<div className="flex">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i.toString()}
className="text-muted-foreground w-[80px] text-xs text-center p-2 font-medium"
>
W{i + 1}
</div>
))}
</div>
</div>
{/* Data rows */}
<div className="flex flex-col">
{currentData.map((cohort, rowIndex) => (
<div key={rowIndex.toString()} className="flex">
<div className="min-w-[70px] flex flex-col">
<div className="p-2 text-sm whitespace-nowrap text-muted-foreground">
{cohort.week}
</div>
</div>
<div className="flex">
{cohort.retention.map((value, cellIndex) => (
<RetentionCell key={cellIndex.toString()} percentage={value} />
))}
{/* Fill empty cells - changed length to 6 */}
{Array.from({ length: 6 - cohort.retention.length }).map(
(_, i) => (
<div key={`empty-${i.toString()}`} className="w-[80px] p-px">
<div className="h-full w-full rounded bg-background" />
</div>
),
)}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,131 @@
'use client';
import { useEffect, useState } from 'react';
const PROFILES = [
{
name: 'Joe Bloggs',
email: 'joe@bloggs.com',
avatar: '/avatar.jpg',
stats: {
firstSeen: 'about 2 months',
lastSeen: '41 minutes',
sessions: '8',
avgSession: '5m 59s',
p90Session: '7m 42s',
pageViews: '41',
},
},
{
name: 'Jane Smith',
email: 'jane@smith.com',
avatar: '/avatar-2.jpg',
stats: {
firstSeen: 'about 1 month',
lastSeen: '2 hours',
sessions: '12',
avgSession: '4m 32s',
p90Session: '6m 15s',
pageViews: '35',
},
},
{
name: 'Alex Johnson',
email: 'alex@johnson.com',
avatar: '/avatar-3.jpg',
stats: {
firstSeen: 'about 3 months',
lastSeen: '15 minutes',
sessions: '15',
avgSession: '6m 20s',
p90Session: '8m 10s',
pageViews: '52',
},
},
];
export function ProfilesFeature() {
const [currentIndex, setCurrentIndex] = useState(0);
const [isTransitioning, setIsTransitioning] = useState(true);
useEffect(() => {
const timer = setInterval(() => {
if (currentIndex === PROFILES.length) {
setIsTransitioning(false);
setCurrentIndex(0);
setTimeout(() => setIsTransitioning(true), 50);
} else {
setCurrentIndex((current) => current + 1);
}
}, 3000);
return () => clearInterval(timer);
}, [currentIndex]);
return (
<div className="overflow-hidden">
<div
className={`flex ${isTransitioning ? 'transition-transform duration-500 ease-in-out' : ''}`}
style={{ transform: `translateX(-${currentIndex * 100}%)` }}
>
{[...PROFILES, PROFILES[0]].map((profile, index) => (
<div
key={profile.name + index.toString()}
className="w-full flex-shrink-0 p-8"
>
<div className="row items-center gap-4">
<img src={profile.avatar} className="size-32 rounded-full" />
<div>
<div className="text-3xl font-semibold">{profile.name}</div>
<div className="text-muted-foreground">{profile.email}</div>
</div>
</div>
<div className="mt-8 grid grid-cols-2 gap-4">
<div className="rounded-lg border p-4 bg-background-light">
<div className="text-sm text-muted-foreground">First seen</div>
<div className="text-lg font-medium">
{profile.stats.firstSeen}
</div>
</div>
<div className="rounded-lg border p-4 bg-background-light">
<div className="text-sm text-muted-foreground">Last seen</div>
<div className="text-lg font-medium">
{profile.stats.lastSeen}
</div>
</div>
<div className="rounded-lg border p-4 bg-background-light">
<div className="text-sm text-muted-foreground">Sessions</div>
<div className="text-lg font-medium">
{profile.stats.sessions}
</div>
</div>
<div className="rounded-lg border p-4 bg-background-light">
<div className="text-sm text-muted-foreground">
Avg. Session
</div>
<div className="text-lg font-medium">
{profile.stats.avgSession}
</div>
</div>
<div className="rounded-lg border p-4 bg-background-light">
<div className="text-sm text-muted-foreground">
P90. Session
</div>
<div className="text-lg font-medium">
{profile.stats.p90Session}
</div>
</div>
<div className="rounded-lg border p-4 bg-background-light">
<div className="text-sm text-muted-foreground">Page views</div>
<div className="text-lg font-medium">
{profile.stats.pageViews}
</div>
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,196 @@
'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 { useEffect, useState } from 'react';
const TRAFFIC_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,
},
{
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Ftwitter.com',
name: 'Twitter',
percentage: 10,
value: 412,
},
];
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 },
];
export function WebAnalyticsFeature() {
const [currentSourceIndex, setCurrentSourceIndex] = useState(0);
const [currentCountryIndex, setCurrentCountryIndex] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCurrentSourceIndex((prev) => (prev + 1) % TRAFFIC_SOURCES.length);
setCurrentCountryIndex((prev) => (prev + 1) % COUNTRIES.length);
}, 3000);
return () => clearInterval(interval);
}, []);
return (
<div className="p-8 relative col gap-4">
<div className="relative">
<MetricCard
title="Session duration"
value="3m 23s"
change="3%"
chartPoints={[40, 10, 20, 43, 20, 40, 30, 37, 40, 34, 50, 31]}
color="hsl(var(--red))"
className="w-full rotate-3 -left-2 hover:-translate-y-1 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="hsl(var(--green))"
className="w-full -mt-8 -rotate-2 left-2 top-14 hover:-translate-y-1 transition-all duration-300"
/>
</div>
<div>
<div className="-rotate-2 bg-background-light rounded-lg col gap-2 p-2 shadow-lg">
<BarCell {...TRAFFIC_SOURCES[currentSourceIndex]} />
<BarCell
{...TRAFFIC_SOURCES[
(currentSourceIndex + 1) % TRAFFIC_SOURCES.length
]}
/>
</div>
<div className="rotate-1 bg-background-light rounded-lg col gap-2 p-2 shadow-lg">
<BarCell {...COUNTRIES[currentCountryIndex]} />
<BarCell
{...COUNTRIES[(currentCountryIndex + 1) % COUNTRIES.length]}
/>
</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(
'row items-end bg-background-light rounded-lg p-4 pb-6 border justify-between',
className,
)}
>
<div>
<div className="text-muted-foreground text-xl">{title}</div>
<div className="text-5xl font-bold font-mono">{value}</div>
</div>
<div className="row gap-2 items-center font-mono font-medium text-lg">
<div
className="size-6 rounded-full flex items-center justify-center"
style={{
background: color,
}}
>
<ArrowUpIcon className="size-4" strokeWidth={3} />
</div>
<div>{change}</div>
</div>
<SimpleChart
width={500}
height={30}
points={chartPoints}
className="absolute bottom-0 left-0 right-0"
strokeColor={color}
/>
</div>
);
}
function BarCell({
icon,
name,
percentage,
value,
}: {
icon: string;
name: string;
percentage: number;
value: number;
}) {
return (
<div className="relative p-2">
<div
className="absolute bg-background-dark 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">
{icon.startsWith('http') ? (
<img
alt="serie icon"
className="max-h-4 rounded-[2px] object-contain"
src={icon}
/>
) : (
<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">
<span className="text-muted-foreground">
<NumberFlow value={percentage} />%
</span>
<NumberFlow value={value} locales={'en-US'} />
</div>
</div>
</div>
);
}