onboarding completed

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-04-16 11:41:15 +02:00
committed by Carl-Gerhard Lindesvärd
parent 97627583ec
commit 7d22d2ddad
79 changed files with 2542 additions and 805 deletions

View File

@@ -1,22 +1,27 @@
'use client';
import { useState } from 'react';
import { SerieIcon } from '@/components/report/chart/SerieIcon';
import { Tooltiper } from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/useAppParams';
import { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn';
import Link from 'next/link';
import type { IServiceCreateEventPayload } from '@openpanel/db';
import type {
IServiceCreateEventPayload,
IServiceEventMinimal,
} from '@openpanel/db';
import { EventDetails } from './event-details';
import { EventIcon } from './event-icon';
type EventListItemProps = IServiceCreateEventPayload;
type EventListItemProps = IServiceEventMinimal | IServiceCreateEventPayload;
export function EventListItem(props: EventListItemProps) {
const { organizationSlug, projectId } = useAppParams();
const { createdAt, name, path, duration, meta, profile } = props;
const { createdAt, name, path, duration, meta } = props;
const profile = 'profile' in props ? props.profile : null;
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
const number = useNumber();
@@ -45,28 +50,50 @@ export function EventListItem(props: EventListItemProps) {
return null;
};
const isMinimal = 'minimal' in props;
return (
<>
<EventDetails
event={props}
open={isDetailsOpen}
setOpen={setIsDetailsOpen}
/>
{!isMinimal && (
<EventDetails
event={props}
open={isDetailsOpen}
setOpen={setIsDetailsOpen}
/>
)}
<button
onClick={() => setIsDetailsOpen(true)}
onClick={() => {
if (!isMinimal) {
setIsDetailsOpen(true);
}
}}
className={cn(
'card hover:bg-light-background flex w-full items-center justify-between rounded-lg p-4 transition-colors',
meta?.conversion &&
`bg-${meta.color}-50 dark:bg-${meta.color}-900 hover:bg-${meta.color}-100 dark:hover:bg-${meta.color}-700`
)}
>
<div className="flex items-center gap-4 text-left text-sm">
<EventIcon size="sm" name={name} meta={meta} projectId={projectId} />
<span>
<span className="font-medium">{renderName()}</span>
{' '}
{renderDuration()}
</span>
<div>
<div className="flex items-center gap-4 text-left text-sm">
<EventIcon
size="sm"
name={name}
meta={meta}
projectId={projectId}
/>
<span>
<span className="font-medium">{renderName()}</span>
{' '}
{renderDuration()}
</span>
</div>
<div className="pl-10">
<div className="flex origin-left scale-75 gap-1">
<SerieIcon name={props.country} />
<SerieIcon name={props.os} />
<SerieIcon name={props.browser} />
</div>
</div>
</div>
<div className="flex gap-4">
<Tooltiper

View File

@@ -1,20 +1,19 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useState } from 'react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/useAppParams';
import useWS from '@/hooks/useWS';
import { cn } from '@/utils/cn';
import { useQueryClient } from '@tanstack/react-query';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/navigation';
import useWebSocket from 'react-use-websocket';
import { toast } from 'sonner';
import type { IServiceCreateEventPayload } from '@openpanel/db';
import type { IServiceEventMinimal } from '@openpanel/db';
const AnimatedNumbers = dynamic(() => import('react-animated-numbers'), {
ssr: false,
@@ -24,21 +23,13 @@ const AnimatedNumbers = dynamic(() => import('react-animated-numbers'), {
export default function EventListener() {
const router = useRouter();
const { projectId } = useAppParams();
const ws = String(process.env.NEXT_PUBLIC_API_URL)
.replace(/^https/, 'wss')
.replace(/^http/, 'ws');
const [counter, setCounter] = useState(0);
const [socketUrl] = useState(`${ws}/live/events/${projectId}`);
useWebSocket(socketUrl, {
shouldReconnect: () => true,
onMessage(payload) {
const event = JSON.parse(payload.data) as IServiceCreateEventPayload;
if (event?.name) {
setCounter((prev) => prev + 1);
toast(`New event ${event.name} from ${event.country}!`);
}
},
useWS<IServiceEventMinimal>(`/live/events/${projectId}`, (event) => {
if (event?.name) {
setCounter((prev) => prev + 1);
toast(`New event ${event.name} from ${event.country}!`);
}
});
return (

View File

@@ -28,22 +28,16 @@ export default async function AppLayout({
if (!organizations.find((item) => item.slug === organizationSlug)) {
return (
<FullPageEmptyState
title="Could not find organization"
className="min-h-screen"
>
The organization you are looking for could not be found.
<FullPageEmptyState title="Not found" className="min-h-screen">
The organization you were looking for could not be found.
</FullPageEmptyState>
);
}
if (!projects.find((item) => item.id === projectId)) {
return (
<FullPageEmptyState
title="Could not find project"
className="min-h-screen"
>
The project you are looking for could not be found.
<FullPageEmptyState title="Not found" className="min-h-screen">
The project you were looking for could not be found.
</FullPageEmptyState>
);
}

View File

@@ -1,8 +1,7 @@
'use client';
import { buttonVariants } from '@/components/ui/button';
import SignOutButton from '@/components/sign-out-button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { SignOutButton } from '@clerk/nextjs';
export function Logout() {
return (
@@ -14,10 +13,7 @@ export function Logout() {
<p className="mb-4">
Sometime&apos;s you need to go. See you next time
</p>
<SignOutButton
// @ts-expect-error
className={buttonVariants({ variant: 'destructive' })}
/>
<SignOutButton />
</WidgetBody>
</Widget>
);

View File

@@ -1,79 +0,0 @@
'use client';
import { LogoSquare } from '@/components/logo';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAppParams } from '@/hooks/useAppParams';
import { api, handleError } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { SaveIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const validation = z.object({
name: z.string().min(1),
});
type IForm = z.infer<typeof validation>;
export function CreateProject() {
const params = useAppParams();
const router = useRouter();
const form = useForm<IForm>({
resolver: zodResolver(validation),
});
const mutation = api.project.create.useMutation({
onError: handleError,
onSuccess() {
toast.success('Project created');
router.refresh();
},
});
const onSubmit: SubmitHandler<IForm> = (values) => {
mutation.mutate({
name: values.name,
organizationSlug: params.organizationSlug,
});
};
return (
<>
<div>
<LogoSquare className="mb-8 w-20 md:w-28" />
<h1 className="text-3xl font-medium">Create your first project</h1>
<div className="text-lg">
A project is just a container for your events. You can create as many
as you want.
</div>
<form
className="mt-8 flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<div>
<Label>Project name</Label>
<Input
placeholder="My App"
size="large"
error={form.formState.errors.name?.message}
{...form.register('name')}
/>
</div>
<div className="flex justify-end">
<Button
type="submit"
size="lg"
icon={SaveIcon}
loading={mutation.isLoading}
>
Create project
</Button>
</div>
</form>
</div>
</>
);
}

View File

@@ -1,16 +1,15 @@
import { LogoSquare } from '@/components/logo';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullWidthNavbar from '@/components/full-width-navbar';
import { ProjectCard } from '@/components/projects/project-card';
import { SignOutButton } from '@clerk/nextjs';
import SignOutButton from '@/components/sign-out-button';
import { notFound, redirect } from 'next/navigation';
import {
getCurrentOrganizations,
getCurrentProjects,
getOrganizationBySlug,
isWaitlistUserAccepted,
} from '@openpanel/db';
import { CreateProject } from './create-project';
interface PageProps {
params: {
organizationSlug: string;
@@ -20,41 +19,25 @@ interface PageProps {
export default async function Page({
params: { organizationSlug },
}: PageProps) {
const [organization, projects] = await Promise.all([
getOrganizationBySlug(organizationSlug),
const [organizations, projects] = await Promise.all([
getCurrentOrganizations(),
getCurrentProjects(organizationSlug),
]);
if (!organization) {
return notFound();
}
const organization = organizations.find(
(org) => org.slug === organizationSlug
);
if (process.env.BLOCK) {
const isAccepted = await isWaitlistUserAccepted();
if (!isAccepted) {
return (
<div className="flex h-screen items-center justify-center p-4">
<div className="w-full max-w-lg">
<LogoSquare className="mb-8 w-20 md:w-28" />
<h1 className="text-3xl font-medium">Not quite there yet</h1>
<div className="text-lg">
We&apos;re still working on Openpanel, but we&apos;re not quite
there yet. We&apos;ll let you know when we&apos;re ready to go!
</div>
</div>
</div>
);
}
if (!organization) {
return (
<FullPageEmptyState title="Not found" className="min-h-screen">
The organization you were looking for could not be found.
</FullPageEmptyState>
);
}
if (projects.length === 0) {
return (
<div className="flex h-screen items-center justify-center p-4 ">
<div className="w-full max-w-lg">
<CreateProject />
</div>
</div>
);
return redirect('/onboarding');
}
if (projects.length === 1 && projects[0]) {
@@ -62,12 +45,16 @@ export default async function Page({
}
return (
<div className="mx-auto flex w-full max-w-xl flex-col gap-4 p-4 pt-20 ">
<SignOutButton />
<h1 className="text-xl font-medium">Select project</h1>
{projects.map((item) => (
<ProjectCard key={item.id} {...item} />
))}
<div>
<FullWidthNavbar>
<SignOutButton />
</FullWidthNavbar>
<div className="mx-auto flex w-full max-w-xl flex-col gap-4 p-4 pt-20 ">
<h1 className="text-xl font-medium">Select project</h1>
{projects.map((item) => (
<ProjectCard key={item.id} {...item} />
))}
</div>
</div>
);
}

View File

@@ -1,150 +0,0 @@
'use client';
import { useState } from 'react';
import AnimateHeight from '@/components/animate-height';
import { CreateClientSuccess } from '@/components/clients/create-client-success';
import { LogoSquare } from '@/components/logo';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button, buttonVariants } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { api, handleError } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { zodResolver } from '@hookform/resolvers/zod';
import { SaveIcon, WallpaperIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const validation = z.object({
organization: z.string().min(3),
project: z.string().min(3),
cors: z.string().url().or(z.literal('')),
});
type IForm = z.infer<typeof validation>;
export function CreateOrganization() {
const router = useRouter();
const [hasDomain, setHasDomain] = useState(true);
const form = useForm<IForm>({
resolver: zodResolver(validation),
defaultValues: {
organization: '',
project: '',
cors: '',
},
});
const mutation = api.onboarding.organziation.useMutation({
onError: handleError,
});
const onSubmit: SubmitHandler<IForm> = (values) => {
mutation.mutate({
...values,
cors: hasDomain ? values.cors : null,
});
};
if (mutation.isSuccess && mutation.data.client) {
return (
<div className="card p-4 md:p-8">
<LogoSquare className="mb-4 w-20" />
<h1 className="text-3xl font-medium">Nice job!</h1>
<div className="mb-4">
You&apos;re ready to start using our SDK. Save the client ID and
secret (if you have any)
</div>
<CreateClientSuccess {...mutation.data.client} />
<div className="mt-4 flex gap-4">
<a
className={cn(buttonVariants({ variant: 'secondary' }), 'flex-1')}
href="https://docs.openpanel.dev/docs"
target="_blank"
>
Read docs
</a>
<Button
className="flex-1"
onClick={() => router.refresh()}
icon={WallpaperIcon}
>
Dashboard
</Button>
</div>
</div>
);
}
return (
<div className="card p-4 md:p-8">
<LogoSquare className="mb-4 w-20" />
<h1 className="text-3xl font-medium">Welcome to Openpanel</h1>
<div className="text-lg">
Create your organization below (can be personal or a company) and your
first project.
</div>
<Alert className="mt-8">
<AlertTitle>Free during beta</AlertTitle>
<AlertDescription>
Openpanel is free during beta. Check our{' '}
<a
href="https://openpanel.dev/#pricing"
target="_blank"
className="text-blue-600 underline"
>
pricing
</a>{' '}
if you&apos;re curious. We&apos;ll also have a free tier.
</AlertDescription>
</Alert>
<form
className="mt-8 flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<div>
<Label>Organization name *</Label>
<Input
placeholder="Organization name"
error={form.formState.errors.organization?.message}
{...form.register('organization')}
/>
</div>
<div>
<Label>Project name *</Label>
<Input
placeholder="Project name"
error={form.formState.errors.project?.message}
{...form.register('project')}
/>
</div>
<div>
<Label className="flex items-center justify-between">
<span>Domain</span>
<Switch checked={hasDomain} onCheckedChange={setHasDomain} />
</Label>
<AnimateHeight open={hasDomain}>
<Input
placeholder="https://example.com"
error={form.formState.errors.cors?.message}
{...form.register('cors')}
/>
</AnimateHeight>
</div>
<div className="flex justify-end">
<Button
type="submit"
size="lg"
icon={SaveIcon}
loading={mutation.isLoading}
>
Create
</Button>
</div>
</form>
</div>
);
}

View File

@@ -1,41 +1,13 @@
// import { CreateOrganization } from '@clerk/nextjs';
import { LogoSquare } from '@/components/logo';
import { redirect } from 'next/navigation';
import { getCurrentOrganizations, isWaitlistUserAccepted } from '@openpanel/db';
import { CreateOrganization } from './create-organization';
import { getCurrentOrganizations } from '@openpanel/db';
export default async function Page() {
const organizations = await getCurrentOrganizations();
if (process.env.BLOCK) {
const isAccepted = await isWaitlistUserAccepted();
if (!isAccepted) {
return (
<div className="flex h-screen items-center justify-center">
<div className="w-full max-w-lg">
<LogoSquare className="mb-8 w-20 md:w-28" />
<h1 className="text-3xl font-medium">Not quite there yet</h1>
<div className="text-lg">
We&apos;re still working on Openpanel, but we&apos;re not quite
there yet. We&apos;ll let you know when we&apos;re ready to go!
</div>
</div>
</div>
);
}
}
if (organizations.length > 0) {
return redirect(`/${organizations[0]?.slug}`);
}
return (
<div className="flex h-screen items-center justify-center">
<div className="w-full max-w-lg">
<CreateOrganization />
</div>
</div>
);
return redirect('/onboarding');
}