improve onboarding flow
This commit is contained in:
@@ -1,7 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { ButtonContainer } from '@/components/button-container';
|
import { ButtonContainer } from '@/components/button-container';
|
||||||
|
import { InputWithLabel } from '@/components/forms/input-with-label';
|
||||||
|
import { Alert } from '@/components/ui/alert';
|
||||||
import { LinkButton } from '@/components/ui/button';
|
import { LinkButton } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { KeyIcon, LockIcon } from 'lucide-react';
|
||||||
|
|
||||||
import type { IServiceProjectWithClients } from '@openpanel/db';
|
import type { IServiceProjectWithClients } from '@openpanel/db';
|
||||||
|
|
||||||
@@ -32,6 +37,18 @@ const Connect = ({ project }: Props) => {
|
|||||||
</OnboardingDescription>
|
</OnboardingDescription>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<div className="flex flex-col gap-4 rounded-xl border bg-slate-100 p-4 md:p-6">
|
||||||
|
<div className="flex items-center gap-2 text-2xl capitalize">
|
||||||
|
<LockIcon />
|
||||||
|
Credentials
|
||||||
|
</div>
|
||||||
|
<InputWithLabel label="Client ID" disabled value={client.id} />
|
||||||
|
<InputWithLabel
|
||||||
|
label="Client Secret"
|
||||||
|
disabled
|
||||||
|
value={client.secret ?? 'unknown'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{project.types.map((type) => {
|
{project.types.map((type) => {
|
||||||
const Component = {
|
const Component = {
|
||||||
website: ConnectWeb,
|
website: ConnectWeb,
|
||||||
|
|||||||
@@ -6,21 +6,33 @@ import { ButtonContainer } from '@/components/button-container';
|
|||||||
import { CheckboxItem } from '@/components/forms/checkbox-item';
|
import { CheckboxItem } from '@/components/forms/checkbox-item';
|
||||||
import { InputWithLabel } from '@/components/forms/input-with-label';
|
import { InputWithLabel } from '@/components/forms/input-with-label';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
import { api, handleError } from '@/trpc/client';
|
import { api, handleError } from '@/trpc/client';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { MonitorIcon, ServerIcon, SmartphoneIcon } from 'lucide-react';
|
import {
|
||||||
|
Building,
|
||||||
|
MonitorIcon,
|
||||||
|
ServerIcon,
|
||||||
|
SmartphoneIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import type { SubmitHandler } from 'react-hook-form';
|
import type { SubmitHandler } from 'react-hook-form';
|
||||||
import { Controller, useForm, useWatch } from 'react-hook-form';
|
import { Controller, useForm, useWatch } from 'react-hook-form';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import type { IServiceOrganization } from '@openpanel/db';
|
||||||
import { zOnboardingProject } from '@openpanel/validation';
|
import { zOnboardingProject } from '@openpanel/validation';
|
||||||
|
|
||||||
import OnboardingLayout, { OnboardingDescription } from '../onboarding-layout';
|
import OnboardingLayout, { OnboardingDescription } from '../onboarding-layout';
|
||||||
|
|
||||||
type IForm = z.infer<typeof zOnboardingProject>;
|
type IForm = z.infer<typeof zOnboardingProject>;
|
||||||
|
|
||||||
const Tracking = () => {
|
const Tracking = ({
|
||||||
|
organizations,
|
||||||
|
}: {
|
||||||
|
organizations: IServiceOrganization[];
|
||||||
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const mutation = api.onboarding.project.useMutation({
|
const mutation = api.onboarding.project.useMutation({
|
||||||
onError: handleError,
|
onError: handleError,
|
||||||
@@ -81,13 +93,43 @@ const Tracking = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<InputWithLabel
|
{organizations.length > 0 ? (
|
||||||
label="Workspace name"
|
<Controller
|
||||||
info="This is the name of your workspace. It can be anything you like."
|
control={form.control}
|
||||||
placeholder="Eg. The Music Company"
|
name="organizationSlug"
|
||||||
error={form.formState.errors.organization?.message}
|
render={({ field, formState }) => {
|
||||||
{...form.register('organization')}
|
return (
|
||||||
/>
|
<div>
|
||||||
|
<Label>Workspace name</Label>
|
||||||
|
<Combobox
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Select workspace"
|
||||||
|
icon={Building}
|
||||||
|
error={formState.errors.organizationSlug?.message}
|
||||||
|
value={field.value}
|
||||||
|
items={
|
||||||
|
organizations
|
||||||
|
.filter((item) => item.slug)
|
||||||
|
.map((item) => ({
|
||||||
|
label: item.name,
|
||||||
|
value: item.slug,
|
||||||
|
})) ?? []
|
||||||
|
}
|
||||||
|
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')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<InputWithLabel
|
<InputWithLabel
|
||||||
label="Project name"
|
label="Project name"
|
||||||
placeholder="Eg. The Music App"
|
placeholder="Eg. The Music App"
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { getCurrentOrganizations } from '@openpanel/db';
|
||||||
|
|
||||||
import OnboardingTracking from './onboarding-tracking';
|
import OnboardingTracking from './onboarding-tracking';
|
||||||
|
|
||||||
const Tracking = () => {
|
const Tracking = async () => {
|
||||||
return <OnboardingTracking />;
|
return <OnboardingTracking organizations={await getCurrentOrganizations()} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Tracking;
|
export default Tracking;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface ComboboxProps<T> {
|
|||||||
label?: string;
|
label?: string;
|
||||||
align?: 'start' | 'end' | 'center';
|
align?: 'start' | 'end' | 'center';
|
||||||
portal?: boolean;
|
portal?: boolean;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExtendedComboboxProps<T> = Omit<
|
export type ExtendedComboboxProps<T> = Omit<
|
||||||
@@ -59,6 +60,7 @@ export function Combobox<T extends string>({
|
|||||||
size,
|
size,
|
||||||
align = 'start',
|
align = 'start',
|
||||||
portal,
|
portal,
|
||||||
|
error,
|
||||||
}: ComboboxProps<T>) {
|
}: ComboboxProps<T>) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [search, setSearch] = React.useState('');
|
const [search, setSearch] = React.useState('');
|
||||||
@@ -77,7 +79,11 @@ export function Combobox<T extends string>({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className={cn('justify-between', className)}
|
className={cn(
|
||||||
|
'justify-between',
|
||||||
|
!!error && 'border-destructive',
|
||||||
|
className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center">
|
<div className="flex min-w-0 items-center">
|
||||||
{Icon ? <Icon size={16} className="mr-2 shrink-0" /> : null}
|
{Icon ? <Icon size={16} className="mr-2 shrink-0" /> : null}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { clerkClient } from '@clerk/fastify';
|
import { clerkClient } from '@clerk/fastify';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import { hashPassword, slug, stripTrailingSlash } from '@openpanel/common';
|
import { hashPassword, slug, stripTrailingSlash } from '@openpanel/common';
|
||||||
import { db, getId } from '@openpanel/db';
|
import { db, getId } from '@openpanel/db';
|
||||||
@@ -8,6 +9,27 @@ import { zOnboardingProject } from '@openpanel/validation';
|
|||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||||
|
|
||||||
|
async function createOrGetOrganization(
|
||||||
|
input: z.infer<typeof zOnboardingProject>,
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
if (input.organizationSlug) {
|
||||||
|
return await clerkClient.organizations.getOrganization({
|
||||||
|
slug: input.organizationSlug,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.organization) {
|
||||||
|
return await clerkClient.organizations.createOrganization({
|
||||||
|
name: input.organization,
|
||||||
|
slug: slug(input.organization),
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export const onboardingRouter = createTRPCRouter({
|
export const onboardingRouter = createTRPCRouter({
|
||||||
project: protectedProcedure
|
project: protectedProcedure
|
||||||
.input(zOnboardingProject)
|
.input(zOnboardingProject)
|
||||||
@@ -17,13 +39,12 @@ export const onboardingRouter = createTRPCRouter({
|
|||||||
if (input.app) types.push('app');
|
if (input.app) types.push('app');
|
||||||
if (input.backend) types.push('backend');
|
if (input.backend) types.push('backend');
|
||||||
|
|
||||||
const organization = await clerkClient.organizations.createOrganization({
|
const organization = await createOrGetOrganization(
|
||||||
name: input.organization,
|
input,
|
||||||
slug: slug(input.organization),
|
ctx.session.userId
|
||||||
createdBy: ctx.session.userId,
|
);
|
||||||
});
|
|
||||||
|
|
||||||
if (!organization.slug) {
|
if (!organization?.slug) {
|
||||||
throw new Error('Organization slug is missing');
|
throw new Error('Organization slug is missing');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,8 @@ export const zCreateReference = z.object({
|
|||||||
|
|
||||||
export const zOnboardingProject = z
|
export const zOnboardingProject = z
|
||||||
.object({
|
.object({
|
||||||
organization: z.string().min(3),
|
organization: z.string().optional(),
|
||||||
|
organizationSlug: z.string().optional(),
|
||||||
project: z.string().min(3),
|
project: z.string().min(3),
|
||||||
domain: z.string().url().or(z.literal('').or(z.null())),
|
domain: z.string().url().or(z.literal('').or(z.null())),
|
||||||
website: z.boolean(),
|
website: z.boolean(),
|
||||||
@@ -108,6 +109,19 @@ export const zOnboardingProject = z
|
|||||||
backend: z.boolean(),
|
backend: z.boolean(),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
|
if (!data.organization && !data.organizationSlug) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: 'Organization is required',
|
||||||
|
path: ['organization'],
|
||||||
|
});
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: 'Organization is required',
|
||||||
|
path: ['organizationSlug'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (data.website && !data.domain) {
|
if (data.website && !data.domain) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: 'custom',
|
code: 'custom',
|
||||||
|
|||||||
Reference in New Issue
Block a user