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:
committed by
GitHub
parent
436e81ecc9
commit
81a7e5d62e
57
apps/start/src/components/onboarding/connect-app.tsx
Normal file
57
apps/start/src/components/onboarding/connect-app.tsx
Normal 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;
|
||||
86
apps/start/src/components/onboarding/connect-backend.tsx
Normal file
86
apps/start/src/components/onboarding/connect-backend.tsx
Normal 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;
|
||||
78
apps/start/src/components/onboarding/connect-web.tsx
Normal file
78
apps/start/src/components/onboarding/connect-web.tsx
Normal 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;
|
||||
72
apps/start/src/components/onboarding/curl-preview.tsx
Normal file
72
apps/start/src/components/onboarding/curl-preview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
71
apps/start/src/components/onboarding/skip-onboarding.tsx
Normal file
71
apps/start/src/components/onboarding/skip-onboarding.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
117
apps/start/src/components/onboarding/steps.tsx
Normal file
117
apps/start/src/components/onboarding/steps.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user