feat: dashboard v2, esm, upgrades (#211)

* esm

* wip

* wip

* wip

* wip

* wip

* wip

* subscription notice

* wip

* wip

* wip

* fix envs

* fix: update docker build

* fix

* esm/types

* delete dashboard :D

* add patches to dockerfiles

* update packages + catalogs + ts

* wip

* remove native libs

* ts

* improvements

* fix redirects and fetching session

* try fix favicon

* fixes

* fix

* order and resize reportds within a dashboard

* improvements

* wip

* added userjot to dashboard

* fix

* add op

* wip

* different cache key

* improve date picker

* fix table

* event details loading

* redo onboarding completely

* fix login

* fix

* fix

* extend session, billing and improve bars

* fix

* reduce price on 10M
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-10-16 12:27:44 +02:00
committed by GitHub
parent 436e81ecc9
commit 81a7e5d62e
741 changed files with 32695 additions and 16996 deletions

View File

@@ -0,0 +1,57 @@
import { pushModal } from '@/modals';
import { SmartphoneIcon } from 'lucide-react';
import type { IServiceClient } from '@openpanel/db';
import { frameworks } from '@openpanel/sdk-info';
type Props = {
client: IServiceClient | null;
};
const ConnectApp = ({ client }: Props) => {
return (
<div>
<div className="flex items-center gap-2 text-xl font-bold capitalize mb-1">
<SmartphoneIcon className="size-4" />
App
</div>
<div className="text-muted-foreground mb-2">
Pick a framework below to get started.
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
{frameworks
.filter((framework) => framework.type.includes('app'))
.map((framework) => (
<button
type="button"
onClick={() =>
pushModal('Instructions', {
framework,
client,
})
}
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
key={framework.name}
>
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
<framework.IconComponent className="h-full w-full" />
</div>
<div className="flex-1 font-semibold">{framework.name}</div>
</button>
))}
</div>
<p className="mt-2 text-sm text-muted-foreground">
Missing a framework?{' '}
<a
href="mailto:hello@openpanel.dev"
className="text-foreground underline"
>
Let us know!
</a>
</p>
</div>
);
};
export default ConnectApp;

View File

@@ -0,0 +1,86 @@
import { pushModal } from '@/modals';
import { ServerIcon } from 'lucide-react';
import Syntax from '@/components/syntax';
import { useAppContext } from '@/hooks/use-app-context';
import type { IServiceClient } from '@openpanel/db';
import { frameworks } from '@openpanel/sdk-info';
type Props = {
client: IServiceClient | null;
};
const ConnectBackend = ({ client }: Props) => {
const context = useAppContext();
return (
<>
<div>
<div>
<div className="flex items-center gap-2 text-xl font-bold capitalize mb-1">
<ServerIcon className="size-4" />
Backend
</div>
<div className="text-muted-foreground mb-2">
Try with a basic curl command
</div>
</div>
<Syntax
language="bash"
className="border"
code={`curl -X POST ${context.apiUrl}/track \\
-H "Content-Type: application/json" \\
-H "openpanel-client-id: ${client?.id}" \\
-H "openpanel-client-secret: ${client?.secret}" \\
-d '{
"type": "track",
"payload": {
"name": "test_event",
"properties": {
"test": "property"
}
}
}'`}
/>
</div>
<div>
<p className="text-muted-foreground mb-2">
Pick a framework below to get started.
</p>
<div className="grid gap-4 md:grid-cols-2">
{frameworks
.filter((framework) => framework.type.includes('backend'))
.map((framework) => (
<button
type="button"
onClick={() =>
pushModal('Instructions', {
framework,
client,
})
}
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
key={framework.name}
>
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
<framework.IconComponent className="h-full w-full" />
</div>
<div className="flex-1 font-semibold">{framework.name}</div>
</button>
))}
</div>
<p className="mt-2 text-sm text-muted-foreground">
Missing a framework?{' '}
<a
href="mailto:hello@openpanel.dev"
className="text-foreground underline"
>
Let us know!
</a>
</p>
</div>
</>
);
};
export default ConnectBackend;

View File

@@ -0,0 +1,78 @@
import { pushModal } from '@/modals';
import { MonitorIcon } from 'lucide-react';
import Syntax from '@/components/syntax';
import type { IServiceClient } from '@openpanel/db';
import { frameworks } from '@openpanel/sdk-info';
type Props = {
client: IServiceClient | null;
};
const ConnectWeb = ({ client }: Props) => {
return (
<>
<div>
<div className="flex items-center gap-2 text-xl font-bold capitalize mb-1">
<MonitorIcon className="size-4" />
Website
</div>
<div className="text-muted-foreground mb-2">
Paste the script to your website
</div>
<Syntax
className="border"
code={`<script>
window.op = window.op||function(...args){(window.op.q=window.op.q||[]).push(args);};
window.op('init', {
clientId: '${client?.id ?? 'YOUR_CLIENT_ID'}',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>`}
/>
</div>
<div>
<p className="text-muted-foreground mb-2">
Or pick a framework below to get started.
</p>
<div className="grid gap-4 md:grid-cols-2">
{frameworks
.filter((framework) => framework.type.includes('website'))
.map((framework) => (
<button
type="button"
onClick={() =>
pushModal('Instructions', {
framework,
client,
})
}
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
key={framework.name}
>
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
<framework.IconComponent className="h-full w-full" />
</div>
<div className="flex-1 font-semibold">{framework.name}</div>
</button>
))}
</div>
<p className="mt-2 text-sm text-muted-foreground">
Missing a framework?{' '}
<a
href="mailto:hello@openpanel.dev"
className="text-foreground underline"
>
Let us know!
</a>
</p>
</div>
</>
);
};
export default ConnectWeb;

View File

@@ -0,0 +1,72 @@
import { useAppContext } from '@/hooks/use-app-context';
import { useClientSecret } from '@/hooks/use-client-secret';
import { clipboard } from '@/utils/clipboard';
import type { IServiceProjectWithClients } from '@openpanel/db';
import Syntax from '../syntax';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '../ui/accordion';
export function CurlPreview({
project,
}: { project: IServiceProjectWithClients }) {
const context = useAppContext();
const [secret] = useClientSecret();
const client = project.clients[0];
if (!client) {
return null;
}
const payload: Record<string, any> = {
type: 'track',
payload: {
name: 'screen_view',
properties: {
__title: `Testing OpenPanel - ${project.name}`,
__path: `${project.domain}`,
__referrer: `${context.dashboardUrl}`,
},
},
};
if (project.types.includes('app')) {
payload.payload.properties.__path = '/';
delete payload.payload.properties.__referrer;
}
if (project.types.includes('backend')) {
payload.payload.name = 'test_event';
payload.payload.properties = {};
}
const code = `curl -X POST ${context.apiUrl}/track \\
-H "Content-Type: application/json" \\
-H "openpanel-client-id: ${client.id}" \\
-H "openpanel-client-secret: ${secret}" \\
-H "User-Agent: ${typeof window !== 'undefined' ? window.navigator.userAgent : ''}" \\
-d '${JSON.stringify(payload)}'`;
return (
<div className="card">
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<AccordionTrigger
className="px-6"
onClick={() => {
clipboard(code, null);
}}
>
Try out the curl command
</AccordionTrigger>
<AccordionContent className="p-0">
<Syntax code={code} language="bash" />
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import useWS from '@/hooks/use-ws';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn';
import { timeAgo } from '@/utils/date';
import { CheckCircle2Icon, CheckIcon, Loader2 } from 'lucide-react';
import { useState } from 'react';
import type {
IServiceClient,
IServiceEvent,
IServiceProject,
} from '@openpanel/db';
type Props = {
project: IServiceProject;
client: IServiceClient | null;
events: IServiceEvent[];
onVerified: (verified: boolean) => void;
};
const VerifyListener = ({
client,
events: _events,
onVerified,
project,
}: Props) => {
const [events, setEvents] = useState<IServiceEvent[]>(_events ?? []);
useWS<IServiceEvent>(
`/live/events/${client?.projectId}?type=received`,
(data) => {
setEvents((prev) => [...prev, data]);
onVerified(true);
},
);
const isConnected = events.length > 0;
const renderIcon = () => {
if (isConnected) {
return (
<CheckCircle2Icon
strokeWidth={1.2}
size={40}
className="shrink-0 text-emerald-600"
/>
);
}
return (
<Loader2 size={40} className="shrink-0 animate-spin text-highlight" />
);
};
return (
<div>
<div
className={cn(
'flex gap-6 rounded-xl p-4 md:p-6',
isConnected ? 'bg-emerald-100 dark:bg-emerald-700' : 'bg-blue-500/10',
)}
>
{renderIcon()}
<div className="flex-1">
<div className="text-lg font-semibold leading-normal text-foreground/90">
{isConnected ? 'Success' : 'Waiting for events'}
</div>
{isConnected ? (
<div className="flex flex-col-reverse">
{events.length > 5 && (
<div className="flex items-center gap-2 ">
<CheckIcon size={14} />{' '}
<span>{events.length - 5} more events</span>
</div>
)}
{events.slice(-5).map((event) => (
<div key={event.id} className="flex items-center gap-2 ">
<CheckIcon size={14} />{' '}
<span className="font-medium">{event.name}</span>{' '}
<span className="ml-auto text-emerald-800">
{timeAgo(event.createdAt, 'round')}
</span>
</div>
))}
</div>
) : (
<div className="text-foreground/50">
Verify that your implementation works.
</div>
)}
</div>
</div>
<div className="mt-2 text-sm text-muted-foreground">
You can{' '}
<button
type="button"
className="underline"
onClick={() => {
pushModal('OnboardingTroubleshoot', {
client,
type: 'app',
});
}}
>
troubleshoot
</button>{' '}
if you are having issues connecting your app.
</div>
</div>
);
};
export default VerifyListener;

View File

@@ -0,0 +1,71 @@
import { useLogout } from '@/hooks/use-logout';
import { useTRPC } from '@/integrations/trpc/react';
import { showConfirm } from '@/modals';
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useLocation, useNavigate } from '@tanstack/react-router';
import { ChevronLastIcon, LogInIcon } from 'lucide-react';
import { useEffect } from 'react';
const PUBLIC_SEGMENTS = [['onboarding']];
export const SkipOnboarding = () => {
const trpc = useTRPC();
const navigate = useNavigate();
const location = useLocation();
const pathname = location.pathname;
const segments = location.pathname.split('/').filter(Boolean);
const isPublic = PUBLIC_SEGMENTS.some((segment) =>
segments.every((s, index) => s === segment[index]),
);
const res = useQuery(
trpc.onboarding.skipOnboardingCheck.queryOptions(undefined, {
enabled: !isPublic,
}),
);
const logout = useLogout();
useEffect(() => {
res.refetch();
}, [pathname]);
// Do not show skip onboarding for the first step (register account)
if (isPublic) {
return (
<Link
to="/login"
className="flex items-center gap-2 text-muted-foreground"
>
Login
<LogInIcon size={16} />
</Link>
);
}
if (res.isLoading || res.isError) {
return null;
}
return (
<button
type="button"
onClick={() => {
if (res.data?.canSkip) {
navigate({ to: '/' });
} else {
showConfirm({
title: 'Skip onboarding?',
text: 'Are you sure you want to skip onboarding? Since you do not have any projects, you will be logged out.',
onConfirm() {
logout.mutate();
},
});
}
}}
className="flex items-center gap-2 text-muted-foreground"
>
Skip onboarding
<ChevronLastIcon size={16} />
</button>
);
};

View File

@@ -0,0 +1,117 @@
import { cn } from '@/lib/utils';
import { useLocation } from '@tanstack/react-router';
import { CheckCheckIcon } from 'lucide-react';
type Step = {
name: string;
status: 'completed' | 'current' | 'pending';
match: string;
};
type Props = {
className?: string;
};
function useSteps(path: string) {
const steps: Step[] = [
{
name: 'Create an account',
status: 'pending',
match: '/onboarding',
},
{
name: 'Create a project',
status: 'pending',
match: '/onboarding/project',
},
{
name: 'Connect your data',
status: 'pending',
match: '/onboarding/(.+)/connect',
},
{
name: 'Verify',
status: 'pending',
match: '/onboarding/(.+)/verify',
},
];
// @ts-ignore
const matchIndex = steps.findLastIndex((step) =>
path.match(new RegExp(step.match)),
);
return steps.map((step, index) => {
if (index < matchIndex) {
return { ...step, status: 'completed' };
}
if (index === matchIndex) {
return { ...step, status: 'current' };
}
return step;
});
}
export const OnboardingSteps = ({ className }: Props) => {
const location = useLocation();
const path = location.pathname;
const steps = useSteps(path);
const currentIndex = steps.findIndex((i) => i.status === 'current');
return (
<div className="relative">
<div className="absolute bottom-4 left-4 top-4 w-px bg-def-200" />
<div
className="absolute left-4 top-4 w-px bg-highlight"
style={{
height: `calc(${((currentIndex + 1) / steps.length) * 100}% - 3.5rem)`,
}}
/>
<div
className={cn(
'relative flex gap-4 overflow-hidden md:-ml-3 md:flex-col md:gap-8',
className,
)}
>
{steps.map((step, index) => (
<div
className={cn(
'flex flex-shrink-0 items-center gap-4 self-start px-3 py-1.5',
step.status === 'current' &&
'rounded-xl border border-border bg-card',
step.status === 'completed' &&
index !== currentIndex - 1 &&
'max-md:hidden',
)}
key={step.name}
>
<div
className={cn(
'relative flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-white',
)}
>
<div
className={cn(
'absolute inset-0 z-0 rounded-full bg-highlight',
step.status === 'pending' && 'bg-def-400',
)}
/>
{step.status === 'current' && (
<div className="absolute inset-1 z-0 animate-ping-slow rounded-full bg-highlight" />
)}
<div className="relative">
{step.status === 'completed' && <CheckCheckIcon size={14} />}
{/* {step.status === 'current' && (
<ArrowRightCircleIcon size={14} />
)} */}
{(step.status === 'pending' || step.status === 'current') &&
index + 1}
</div>
</div>
<div className="font-medium">{step.name}</div>
</div>
))}
</div>
</div>
);
};