Skip to content

Commit d67b952

Browse files
authored
Merge pull request #402 from trycompai/lewis/onboarding-experience
User onboarding
2 parents aa76f3d + 77b1231 commit d67b952

File tree

49 files changed

+662
-204
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+662
-204
lines changed

apps/app/src/actions/organization/create-organization-action.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,15 @@ export const createOrganizationAction = authActionClient
4646
createControlArtifacts: 0,
4747
total: 0,
4848
};
49+
4950
const totalStart = performance.now();
5051
let start = performance.now();
5152

5253
try {
5354
const session = await auth.api.getSession({
5455
headers: await headers(),
5556
});
57+
5658
timings.getAuthSession = (performance.now() - start) / 1000;
5759

5860
if (!session?.session.activeOrganizationId) {
@@ -71,6 +73,12 @@ export const createOrganizationAction = authActionClient
7173
});
7274
}
7375

76+
await db.onboarding.create({
77+
data: {
78+
organizationId: session.session.activeOrganizationId,
79+
},
80+
});
81+
7482
const organizationId = session.session.activeOrganizationId;
7583

7684
// --- External API Call + Initial Org Update (Outside Transaction) ---
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"use server";
2+
3+
import { db } from "@comp/db";
4+
import { Onboarding } from "@comp/db/types";
5+
import { revalidatePath } from "next/cache";
6+
7+
export async function updateOnboardingItem(
8+
orgId: string,
9+
onboardingItem: Exclude<keyof Onboarding, "organizationId">,
10+
value: boolean,
11+
): Promise<{ success: true; error: null } | { success: false; error: string }> {
12+
try {
13+
const onboarding = await db.onboarding.findUnique({
14+
where: { organizationId: orgId },
15+
});
16+
17+
if (!onboarding) {
18+
throw new Error("Onboarding not found");
19+
}
20+
21+
await db.onboarding.update({
22+
where: { organizationId: orgId },
23+
data: { [onboardingItem]: value },
24+
});
25+
26+
revalidatePath(`/${orgId}/implementation`);
27+
28+
return { success: true, error: null };
29+
} catch (error) {
30+
return {
31+
success: false,
32+
error:
33+
error instanceof Error
34+
? error.message
35+
: "An unexpected error occurred",
36+
};
37+
}
38+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"use client";
2+
3+
import { Accordion } from "@comp/ui/accordion";
4+
import { ChecklistProps } from "../types/ChecklistProps.types";
5+
import { ChecklistItem } from "./ChecklistItem";
6+
7+
export function Checklist({ items }: ChecklistProps) {
8+
return (
9+
<div className="flex flex-col gap-4">
10+
{items.map((item) => (
11+
<ChecklistItem key={item.dbColumn} {...item} />
12+
))}
13+
</div>
14+
);
15+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"use client";
2+
3+
import { Badge } from "@comp/ui/badge";
4+
import { Button } from "@comp/ui/button";
5+
import {
6+
Card,
7+
CardContent,
8+
CardDescription,
9+
CardFooter,
10+
CardHeader,
11+
CardTitle,
12+
} from "@comp/ui/card";
13+
import {
14+
ArrowRight,
15+
Loader2,
16+
} from "lucide-react";
17+
import { useParams, useRouter } from "next/navigation";
18+
import { useState } from "react";
19+
import { toast } from "sonner";
20+
import { updateOnboardingItem } from "../actions/update-onboarding-item";
21+
import type { ChecklistItemProps } from "../types/ChecklistProps.types";
22+
23+
export function ChecklistItem({
24+
title,
25+
description,
26+
href,
27+
docs,
28+
dbColumn,
29+
completed,
30+
buttonLabel,
31+
icon,
32+
}: ChecklistItemProps) {
33+
const { orgId } = useParams<{ orgId: string }>();
34+
const linkWithOrgReplaced = href.replace(":organizationId", orgId);
35+
const [isUpdating, setIsUpdating] = useState(false);
36+
const [isAnimating, setIsAnimating] = useState(false);
37+
const router = useRouter();
38+
39+
const handleMarkAsDone = async () => {
40+
try {
41+
setIsUpdating(true);
42+
setIsAnimating(true);
43+
44+
const result = await updateOnboardingItem(orgId, dbColumn, true);
45+
46+
if (!result.success) {
47+
throw new Error(result.error);
48+
}
49+
50+
setTimeout(() => setIsAnimating(false), 600);
51+
router.push(linkWithOrgReplaced);
52+
} catch (error) {
53+
toast.error(
54+
error instanceof Error ? error.message : "Failed to update status",
55+
);
56+
} finally {
57+
setIsUpdating(false);
58+
}
59+
};
60+
61+
return (
62+
<Card>
63+
<div className={completed ? "opacity-40" : ""}>
64+
<CardHeader className="pb-3">
65+
<div className="items-center gap-4 space-y-0">
66+
<CardTitle className="flex items-center gap-4 justify-between w-full">
67+
<span className={completed ? "line-through" : ""}>
68+
{title}
69+
</span>
70+
{completed && (
71+
<Badge
72+
variant="marketing"
73+
>
74+
Completed
75+
</Badge>
76+
)}
77+
</CardTitle>
78+
{description && (
79+
<CardDescription className="text-sm text-muted-foreground flex flex-col gap-4">
80+
{description}
81+
</CardDescription>
82+
)}
83+
</div>
84+
</CardHeader>
85+
<CardContent />
86+
{!completed && (
87+
<CardFooter className="justify-end">
88+
{!completed && (
89+
<Button
90+
variant={completed ? "outline" : "default"}
91+
className="w-full sm:w-fit"
92+
onClick={handleMarkAsDone}
93+
disabled={isUpdating}
94+
>
95+
{completed ? (
96+
"Completed"
97+
) : isUpdating ? (
98+
<>
99+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
100+
</>
101+
) : (
102+
<>
103+
{buttonLabel}
104+
<ArrowRight className="ml-1 h-4 w-4" />
105+
</>
106+
)}
107+
</Button>
108+
)}
109+
</CardFooter>
110+
)}
111+
</div>
112+
</Card>
113+
);
114+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"use client";
2+
3+
import { Alert, AlertDescription, AlertTitle } from "@comp/ui/alert";
4+
import { Progress } from "@comp/ui/progress";
5+
import { useEffect, useState } from "react";
6+
7+
interface OnboardingProgressProps {
8+
totalSteps: number;
9+
completedSteps: number;
10+
}
11+
12+
export function OnboardingProgress({
13+
totalSteps,
14+
completedSteps,
15+
}: OnboardingProgressProps) {
16+
const [progress, setProgress] = useState(0);
17+
18+
useEffect(() => {
19+
const percentage = (completedSteps / totalSteps) * 100;
20+
21+
// Animate progress bar
22+
const timer = setTimeout(() => setProgress(percentage), 100);
23+
return () => clearTimeout(timer);
24+
}, [completedSteps, totalSteps]);
25+
26+
return (
27+
<Alert variant="default" className="bg-primary/5 border-primary/40 rounded-sm">
28+
<AlertTitle>
29+
Welcome to Comp AI!
30+
</AlertTitle>
31+
<AlertDescription>
32+
Complete the steps below to complete your onboarding and get started with Comp AI.
33+
</AlertDescription>
34+
<div className="flex flex-col gap-2 mt-4">
35+
<Progress value={progress} className="w-full h-2" />
36+
<div className="flex justify-between">
37+
<span className="text-xs text-muted-foreground">
38+
{completedSteps} / {totalSteps}
39+
</span>
40+
</div>
41+
</div>
42+
</Alert>
43+
);
44+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export default async function Layout({
2+
children,
3+
}: {
4+
children: React.ReactNode;
5+
}) {
6+
return (
7+
<div className="max-w-[1200px] m-auto">
8+
<main className="mt-8 max-w-[600px] mx-auto">{children}</main>
9+
</div>
10+
);
11+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { db } from "@comp/db";
2+
import { Icons } from "@comp/ui/icons";
3+
import { BookOpen, ListCheck, NotebookText, Store, Users } from "lucide-react";
4+
import { redirect } from "next/navigation";
5+
import { cache } from 'react'
6+
import { Checklist } from "./components/Checklist";
7+
import { OnboardingProgress } from "./components/OnboardingProgress";
8+
import { ChecklistItemProps } from "./types/ChecklistProps.types";
9+
10+
const getChecklistItems = cache(async (orgId: string): Promise<{ checklistItems: ChecklistItemProps[], completedItems: number, totalItems: number } | { error: string }> => {
11+
const onboarding = await db.onboarding.findUnique({
12+
where: {
13+
organizationId: orgId,
14+
},
15+
});
16+
17+
if (!onboarding) {
18+
return { error: "Organization onboarding not found" };
19+
}
20+
21+
const checklistItems: ChecklistItemProps[] = [
22+
{
23+
title: "Check & Publish Policies",
24+
description: "We've given you all of the policies you need to get started. Go through them, make sure they're relevant to your organization and then publish them for your employees to sign.",
25+
href: `/${orgId}/policies`,
26+
dbColumn: "policies",
27+
completed: onboarding.policies,
28+
docs: "https://trycomp.ai/docs/policies",
29+
buttonLabel: "Publish Policies",
30+
icon: <NotebookText className="h-5 w-5" />,
31+
},
32+
{
33+
title: "Add Employees",
34+
description: "You should add all of your employees to Comp AI, either through an integration or by manually adding them and then ask them to sign the policies you published in the employee portal.",
35+
href: `/${orgId}/employees`,
36+
dbColumn: "employees",
37+
completed: onboarding.employees,
38+
docs: "https://trycomp.ai/docs/employees",
39+
buttonLabel: "Add an Employee",
40+
icon: <Users className="h-5 w-5" />,
41+
},
42+
{
43+
title: "Add Vendors",
44+
description: "For frameworks like SOC 2, you must assess and report on your vendors. You can add your vendors to Comp AI, and assign risk levels to them. Auditors can review the vendors and their risk levels.",
45+
href: `/${orgId}/vendors`,
46+
dbColumn: "vendors",
47+
completed: onboarding.vendors,
48+
docs: "https://trycomp.ai/docs/vendors",
49+
buttonLabel: "Add a Vendor",
50+
icon: <Store className="h-5 w-5" />,
51+
},
52+
{
53+
title: "Manage Risks",
54+
description: "You can manage your risks in Comp AI by adding them to your organization and then assigning them to employees or vendors. Auditors can review the risks and their status.",
55+
href: `/${orgId}/risk`,
56+
dbColumn: "risk",
57+
completed: onboarding.risk,
58+
docs: "https://trycomp.ai/docs/risks",
59+
buttonLabel: "Create a Risk",
60+
icon: <Icons.Risk className="h-5 w-5" />,
61+
},
62+
{
63+
title: "Upload Evidence",
64+
description: "Evidence in Comp AI is automatically generated for you, based on the frameworks you selected. Evidence tasks are linked to controls, which are determined by your policies. By uploading evidence, you can show auditors that you are following your own policies.",
65+
href: `/${orgId}/evidence`,
66+
dbColumn: "evidence",
67+
completed: onboarding.evidence,
68+
docs: "https://trycomp.ai/docs/evidence",
69+
buttonLabel: "Upload Evidence",
70+
icon: <ListCheck className="h-5 w-5" />,
71+
}
72+
];
73+
74+
const completedItems = checklistItems.filter((item) => item.completed).length;
75+
const totalItems = checklistItems.length;
76+
77+
if (completedItems === totalItems) {
78+
return redirect(`/${orgId}/frameworks`);
79+
}
80+
81+
return { checklistItems, completedItems, totalItems };
82+
})
83+
84+
export default async function Page({
85+
params,
86+
}: {
87+
params: Promise<{ orgId: string }>;
88+
}) {
89+
const { orgId } = await params;
90+
const checklistData = await getChecklistItems(orgId);
91+
92+
if ('error' in checklistData) {
93+
return <div>{checklistData.error}</div>;
94+
}
95+
96+
return (
97+
<div className="space-y-6">
98+
<OnboardingProgress
99+
completedSteps={checklistData.completedItems}
100+
totalSteps={checklistData.totalItems}
101+
/>
102+
<Checklist items={checklistData.checklistItems} />
103+
</div>
104+
)
105+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Onboarding } from "@comp/db/types";
2+
3+
export interface ChecklistProps {
4+
items: ChecklistItemProps[];
5+
}
6+
7+
export interface ChecklistItemProps {
8+
title: string;
9+
description?: string;
10+
href: string;
11+
docs: string;
12+
dbColumn: Exclude<keyof Onboarding, "organizationId">;
13+
completed: boolean;
14+
buttonLabel: string;
15+
icon: React.ReactNode;
16+
}

apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@ export default async function DashboardPage({
77
}) {
88
const organizationId = (await params).orgId;
99

10-
return redirect(`/${organizationId}/frameworks`);
10+
return redirect(`/${organizationId}/implementation`);
1111
}

apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/settings/members/components/InviteMemberForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ export function InviteMemberForm() {
156156
<Button
157157
type="submit"
158158
disabled={form.formState.isSubmitting}
159-
variant="action"
159+
variant="default"
160160
>
161161
{form.formState.isSubmitting ? (
162162
<>

0 commit comments

Comments
 (0)