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,307 @@
import AnimateHeight from '@/components/animate-height';
import { ButtonContainer } from '@/components/button-container';
import { CheckboxItem } from '@/components/forms/checkbox-item';
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
import TagInput from '@/components/forms/tag-input';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { Label } from '@/components/ui/label';
import { useClientSecret } from '@/hooks/use-client-secret';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { zOnboardingProject } from '@openpanel/validation';
import { useMutation, useQuery } from '@tanstack/react-query';
import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router';
import {
BuildingIcon,
MonitorIcon,
ServerIcon,
SmartphoneIcon,
} from 'lucide-react';
import { useEffect } from 'react';
import {
Controller,
type SubmitHandler,
useForm,
useWatch,
} from 'react-hook-form';
import { z } from 'zod';
const validateSearch = z.object({
inviteId: z.string().optional(),
});
export const Route = createFileRoute('/_steps/onboarding/project')({
component: Component,
validateSearch,
beforeLoad: async ({ context }) => {
if (!context.session.session) {
throw redirect({ to: '/onboarding' });
}
},
loader: async ({ context, location }) => {
const search = validateSearch.safeParse(location.search);
if (search.success && search.data.inviteId) {
await context.queryClient.prefetchQuery(
context.trpc.organization.getInvite.queryOptions({
inviteId: search.data.inviteId,
}),
);
}
},
pendingComponent: FullPageLoadingState,
});
type IForm = z.infer<typeof zOnboardingProject>;
function Component() {
const trpc = useTRPC();
const { data: organizations } = useQuery(
trpc.organization.list.queryOptions(undefined, { initialData: [] }),
);
const [, setSecret] = useClientSecret();
const navigate = useNavigate();
const mutation = useMutation(
trpc.onboarding.project.mutationOptions({
onError: handleError,
onSuccess(res) {
setSecret(res.secret);
navigate({
to: '/onboarding/$projectId/connect',
params: {
projectId: res.projectId!,
},
});
},
}),
);
const form = useForm<IForm>({
resolver: zodResolver(zOnboardingProject),
defaultValues: {
organization: '',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
project: '',
domain: '',
cors: [],
website: false,
app: false,
backend: false,
},
});
const isWebsite = useWatch({
name: 'website',
control: form.control,
});
const isApp = useWatch({
name: 'app',
control: form.control,
});
const isBackend = useWatch({
name: 'backend',
control: form.control,
});
useEffect(() => {
if (!isWebsite) {
form.setValue('domain', null);
form.setValue('cors', []);
}
}, [isWebsite, form]);
const onSubmit: SubmitHandler<IForm> = (values) => {
mutation.mutate(values);
};
useEffect(() => {
form.clearErrors();
}, [isWebsite, isApp, isBackend]);
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="p-4">
<div className="grid gap-4 md:grid-cols-2">
{organizations.length > 0 ? (
<Controller
control={form.control}
name="organizationId"
render={({ field, formState }) => {
return (
<div>
<Label>Workspace</Label>
<Combobox
className="w-full"
placeholder="Select workspace"
icon={BuildingIcon}
error={formState.errors.organizationId?.message}
value={field.value}
items={
organizations
.filter((item) => item.id)
.map((item) => ({
label: item.name,
value: item.id,
})) ?? []
}
onChange={field.onChange}
/>
</div>
);
}}
/>
) : (
<>
<InputWithLabel
label="Workspace name"
info="This is the name of your workspace. It can be anything you like."
placeholder="Eg. The Music Company"
error={form.formState.errors.organization?.message}
{...form.register('organization')}
/>
<Controller
name="timezone"
control={form.control}
render={({ field }) => (
<WithLabel label="Timezone">
<Combobox
placeholder="Select timezone"
items={Intl.supportedValuesOf('timeZone').map((item) => ({
value: item,
label: item,
}))}
value={field.value}
onChange={field.onChange}
className="w-full"
/>
</WithLabel>
)}
/>
</>
)}
<InputWithLabel
label="Project name"
placeholder="Eg. The Music App"
error={form.formState.errors.project?.message}
{...form.register('project')}
className="col-span-2"
/>
</div>
<div className="flex flex-col divide-y mt-4">
<Controller
name="website"
control={form.control}
render={({ field }) => (
<CheckboxItem
error={form.formState.errors.website?.message}
Icon={MonitorIcon}
label="Website"
disabled={isApp}
description="Track events and conversion for your website"
{...field}
>
<AnimateHeight open={isWebsite && !isApp}>
<div className="p-4 pl-14">
<InputWithLabel
label="Domain"
placeholder="Your website address"
{...form.register('domain')}
className="mb-4"
error={form.formState.errors.domain?.message}
onBlur={(e) => {
const value = e.target.value.trim();
if (
value.includes('.') &&
form.getValues().cors.length === 0 &&
!form.formState.errors.domain
) {
form.setValue('cors', [value]);
}
}}
/>
<Controller
name="cors"
control={form.control}
render={({ field }) => (
<WithLabel label="Allowed domains">
<TagInput
{...field}
error={form.formState.errors.cors?.message}
placeholder="Accept events from these domains"
value={field.value ?? []}
renderTag={(tag) =>
tag === '*'
? 'Accept events from any domains'
: tag
}
onChange={(newValue) => {
field.onChange(
newValue.map((item) => {
const trimmed = item.trim();
if (
trimmed.startsWith('http://') ||
trimmed.startsWith('https://') ||
trimmed === '*'
) {
return trimmed;
}
return `https://${trimmed}`;
}),
);
}}
/>
</WithLabel>
)}
/>
</div>
</AnimateHeight>
</CheckboxItem>
)}
/>
<Controller
name="app"
control={form.control}
render={({ field }) => (
<CheckboxItem
error={form.formState.errors.app?.message}
disabled={isWebsite}
Icon={SmartphoneIcon}
label="App"
description="Track events and conversion for your app"
{...field}
/>
)}
/>
<Controller
name="backend"
control={form.control}
render={({ field }) => (
<CheckboxItem
error={form.formState.errors.backend?.message}
Icon={ServerIcon}
label="Backend / API"
description="Track events and conversion for your backend / API"
{...field}
/>
)}
/>
</div>
</div>
<ButtonContainer className="p-4 border-t">
<div />
<Button
type="submit"
size="lg"
className="min-w-28 self-start"
loading={mutation.isPending}
>
Next
</Button>
</ButtonContainer>
</form>
);
}