refactor: Move Onboarding to server components (#816)

* added data fetching to server side and also added some actions

* made refactors

* replaced router.push with redirect

* updated profileUpdateInput

* move components in components folder

* update import for product update action

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Dhruwang Jariwala
2023-09-15 08:28:00 +05:30
committed by GitHub
parent 041bf36f20
commit e741f7d8e5
13 changed files with 218 additions and 72 deletions
+14
View File
@@ -0,0 +1,14 @@
"use server";
import { updateProduct } from "@formbricks/lib/services/product";
import { updateProfile } from "@formbricks/lib/services/profile";
import { TProductUpdateInput } from "@formbricks/types/v1/product";
import { TProfileUpdateInput } from "@formbricks/types/v1/profile";
export async function updateProfileAction(personId: string, updatedProfile: Partial<TProfileUpdateInput>) {
return await updateProfile(personId, updatedProfile);
}
export async function updateProductAction(productId: string, updatedProduct: Partial<TProductUpdateInput>) {
return await updateProduct(productId, updatedProduct);
}
@@ -1,12 +1,12 @@
"use client";
import { updateProfileAction } from "@/app/(app)/onboarding/actions";
import { env } from "@/env.mjs";
import { formbricksEnabled, updateResponse } from "@/lib/formbricks";
import { useProfile } from "@/lib/profile";
import { useProfileMutation } from "@/lib/profile/mutateProfile";
import { ResponseId } from "@formbricks/js";
import { cn } from "@formbricks/lib/cn";
import { Objective } from "@formbricks/types/templates";
import { TProfile } from "@formbricks/types/v1/profile";
import { Button } from "@formbricks/ui";
import { useState } from "react";
import { toast } from "react-hot-toast";
@@ -15,6 +15,7 @@ type ObjectiveProps = {
next: () => void;
skip: () => void;
formbricksResponseId?: ResponseId;
profile: TProfile;
};
type ObjectiveChoice = {
@@ -22,7 +23,7 @@ type ObjectiveChoice = {
id: Objective;
};
const Objective: React.FC<ObjectiveProps> = ({ next, skip, formbricksResponseId }) => {
const Objective: React.FC<ObjectiveProps> = ({ next, skip, formbricksResponseId, profile }) => {
const objectives: Array<ObjectiveChoice> = [
{ label: "Increase conversion", id: "increase_conversion" },
{ label: "Improve user retention", id: "improve_user_retention" },
@@ -32,19 +33,20 @@ const Objective: React.FC<ObjectiveProps> = ({ next, skip, formbricksResponseId
{ label: "Other", id: "other" },
];
const { profile } = useProfile();
const { triggerProfileMutate, isMutatingProfile } = useProfileMutation();
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const [isProfileUpdating, setIsProfileUpdating] = useState(false);
const handleNextClick = async () => {
if (selectedChoice) {
const selectedObjective = objectives.find((objective) => objective.label === selectedChoice);
if (selectedObjective) {
try {
setIsProfileUpdating(true);
const updatedProfile = { ...profile, objective: selectedObjective.id };
await triggerProfileMutate(updatedProfile);
await updateProfileAction(profile.id, updatedProfile);
setIsProfileUpdating(false);
} catch (e) {
setIsProfileUpdating(false);
console.error(e);
toast.error("An error occured saving your settings");
}
@@ -116,7 +118,7 @@ const Objective: React.FC<ObjectiveProps> = ({ next, skip, formbricksResponseId
<Button
size="lg"
variant="darkCTA"
loading={isMutatingProfile}
loading={isProfileUpdating}
disabled={!selectedChoice}
onClick={handleNextClick}
id="objective-next">
@@ -1,38 +1,30 @@
"use client";
import { Logo } from "@/components/Logo";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useProfile } from "@/lib/profile";
import { useProfileMutation } from "@/lib/profile/mutateProfile";
import { fetcher } from "@formbricks/lib/fetcher";
import { ProgressBar } from "@formbricks/ui";
import { Session } from "next-auth";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import useSWR from "swr";
import Greeting from "./Greeting";
import Objective from "./Objective";
import Product from "./Product";
import Role from "./Role";
import { ResponseId } from "@formbricks/js";
import { TProfile } from "@formbricks/types/v1/profile";
import { TProduct } from "@formbricks/types/v1/product";
import { updateProfileAction } from "@/app/(app)/onboarding/actions";
const MAX_STEPS = 6;
interface OnboardingProps {
session: Session | null;
environmentId: string;
profile: TProfile;
product: TProduct;
}
export default function Onboarding({ session }: OnboardingProps) {
const {
data: environment,
error: isErrorEnvironment,
isLoading: isLoadingEnvironment,
} = useSWR(`/api/v1/environments/find-first`, fetcher);
const { profile } = useProfile();
const { triggerProfileMutate } = useProfileMutation();
export default function Onboarding({ session, environmentId, profile, product }: OnboardingProps) {
const [formbricksResponseId, setFormbricksResponseId] = useState<ResponseId | undefined>();
const [currentStep, setCurrentStep] = useState(1);
const [isLoading, setIsLoading] = useState(false);
@@ -42,18 +34,6 @@ export default function Onboarding({ session }: OnboardingProps) {
return currentStep / MAX_STEPS;
}, [currentStep]);
if (!profile || isLoadingEnvironment) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (isErrorEnvironment) {
return <div className="flex h-full w-full items-center justify-center">An error occurred</div>;
}
const skipStep = () => {
setCurrentStep(currentStep + 1);
};
@@ -75,10 +55,10 @@ export default function Onboarding({ session }: OnboardingProps) {
try {
const updatedProfile = { ...profile, onboardingCompleted: true };
await triggerProfileMutate(updatedProfile);
await updateProfileAction(profile.id, updatedProfile);
if (environment) {
router.push(`/environments/${environment.id}/surveys`);
if (environmentId) {
router.push(`/environments/${environmentId}/surveys`);
return;
}
} catch (e) {
@@ -105,14 +85,28 @@ export default function Onboarding({ session }: OnboardingProps) {
<div className="col-span-2" />
</div>
<div className="flex grow items-center justify-center">
{currentStep === 1 && <Greeting next={next} skip={doLater} name={profile.name} session={session} />}
{currentStep === 1 && (
<Greeting next={next} skip={doLater} name={profile.name ? profile.name : ""} session={session} />
)}
{currentStep === 2 && (
<Role next={next} skip={skipStep} setFormbricksResponseId={setFormbricksResponseId} />
<Role
next={next}
skip={skipStep}
setFormbricksResponseId={setFormbricksResponseId}
profile={profile}
/>
)}
{currentStep === 3 && (
<Objective next={next} skip={skipStep} formbricksResponseId={formbricksResponseId} />
<Objective
next={next}
skip={skipStep}
formbricksResponseId={formbricksResponseId}
profile={profile}
/>
)}
{currentStep === 4 && (
<Product done={done} environmentId={environmentId} isLoading={isLoading} product={product} />
)}
{currentStep === 4 && <Product done={done} environmentId={environment.id} isLoading={isLoading} />}
</div>
</div>
);
@@ -1,8 +1,8 @@
"use client";
import { updateProductAction } from "@/app/(app)/onboarding/actions";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useProductMutation } from "@/lib/products/mutateProducts";
import { useProduct } from "@/lib/products/products";
import { TProduct } from "@formbricks/types/v1/product";
import { Button, ColorPicker, ErrorComponent, Input, Label } from "@formbricks/ui";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
@@ -11,12 +11,11 @@ type Product = {
done: () => void;
environmentId: string;
isLoading: boolean;
product: TProduct;
};
const Product: React.FC<Product> = ({ done, isLoading, environmentId }) => {
const Product: React.FC<Product> = ({ done, isLoading, environmentId, product }) => {
const [loading, setLoading] = useState(true);
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
const { triggerProductMutate } = useProductMutation(environmentId);
const [name, setName] = useState("");
const [color, setColor] = useState("##4748b");
@@ -30,7 +29,7 @@ const Product: React.FC<Product> = ({ done, isLoading, environmentId }) => {
};
useEffect(() => {
if (isLoadingProduct) {
if (!product) {
return;
} else if (product && product.name !== "My Product") {
done(); // when product already exists, skip product step entirely
@@ -40,7 +39,7 @@ const Product: React.FC<Product> = ({ done, isLoading, environmentId }) => {
}
setLoading(false);
}
}, [product, done, isLoadingProduct]);
}, [product, done]);
const dummyChoices = ["❤️ Love it!"];
@@ -50,7 +49,7 @@ const Product: React.FC<Product> = ({ done, isLoading, environmentId }) => {
}
try {
await triggerProductMutate({ name, brandColor: color });
await updateProductAction(product.id, { name, brandColor: color });
} catch (e) {
toast.error("An error occured saving your settings");
console.error(e);
@@ -63,11 +62,11 @@ const Product: React.FC<Product> = ({ done, isLoading, environmentId }) => {
done();
};
if (isLoadingProduct || loading) {
if (loading) {
return <LoadingSpinner />;
}
if (isErrorProduct) {
if (!product) {
return <ErrorComponent />;
}
@@ -1,11 +1,11 @@
"use client";
import { cn } from "@/../../packages/lib/cn";
import { cn } from "@formbricks/lib/cn";
import { updateProfileAction } from "@/app/(app)/onboarding/actions";
import { env } from "@/env.mjs";
import { createResponse, formbricksEnabled } from "@/lib/formbricks";
import { useProfile } from "@/lib/profile";
import { useProfileMutation } from "@/lib/profile/mutateProfile";
import { ResponseId, SurveyId } from "@formbricks/js";
import { TProfile } from "@formbricks/types/v1/profile";
import { Button } from "@formbricks/ui";
import { useState } from "react";
import { toast } from "react-hot-toast";
@@ -14,6 +14,7 @@ type RoleProps = {
next: () => void;
skip: () => void;
setFormbricksResponseId: (id: ResponseId) => void;
profile: TProfile;
};
type RoleChoice = {
@@ -21,11 +22,9 @@ type RoleChoice = {
id: "project_manager" | "engineer" | "founder" | "marketing_specialist" | "other";
};
const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId }) => {
const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId, profile }) => {
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const { profile } = useProfile();
const { triggerProfileMutate, isMutatingProfile } = useProfileMutation();
const [isUpdating, setIsUpdating] = useState(false);
const roles: Array<RoleChoice> = [
{ label: "Project Manager", id: "project_manager" },
@@ -40,9 +39,12 @@ const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId }) => {
const selectedRole = roles.find((role) => role.label === selectedChoice);
if (selectedRole) {
try {
setIsUpdating(true);
const updatedProfile = { ...profile, role: selectedRole.id };
await triggerProfileMutate(updatedProfile);
await updateProfileAction(profile.id, updatedProfile);
setIsUpdating(false);
} catch (e) {
setIsUpdating(false);
toast.error("An error occured saving your settings");
console.error(e);
}
@@ -68,7 +70,6 @@ const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId }) => {
<label className="mb-1.5 block text-base font-semibold leading-6 text-slate-900">
What is your role?
</label>
<label className="block text-sm font-normal leading-6 text-slate-500">
Make your Formbricks experience more personalised.
</label>
@@ -114,7 +115,7 @@ const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId }) => {
<Button
size="lg"
variant="darkCTA"
loading={isMutatingProfile}
loading={isUpdating}
disabled={!selectedChoice}
onClick={handleNextClick}
id="role-next">
+13
View File
@@ -0,0 +1,13 @@
export default function Loading() {
return (
<div className="flex h-[100vh] w-[80vw] animate-pulse flex-col items-center justify-between p-12 text-white">
<div className="flex w-full justify-between">
<div className="h-12 w-1/6 rounded-lg bg-gray-200"></div>
<div className="h-12 w-1/3 rounded-lg bg-gray-200"></div>
<div className="h-0 w-1/6"></div>
</div>
<div className="h-1/3 w-1/2 rounded-lg bg-gray-200"></div>
<div className="h-10 w-1/2 rounded-lg bg-gray-200"></div>
</div>
);
}
+17 -2
View File
@@ -1,8 +1,23 @@
export const revalidate = REVALIDATION_INTERVAL;
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getServerSession } from "next-auth";
import Onboarding from "./Onboarding";
import Onboarding from "./components/Onboarding";
import { getEnvironmentByUser } from "@formbricks/lib/services/environment";
import { getProfile } from "@formbricks/lib/services/profile";
import { ErrorComponent } from "@formbricks/ui";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
export default async function OnboardingPage() {
const session = await getServerSession(authOptions);
return <Onboarding session={session} />;
const environment = await getEnvironmentByUser(session?.user);
const profile = await getProfile(session?.user.id!);
const product = await getProductByEnvironmentId(environment?.id!);
if (!environment || !profile || !product) {
return <ErrorComponent />;
}
return <Onboarding session={session} environmentId={environment?.id} profile={profile} product={product} />;
}
+88 -3
View File
@@ -1,10 +1,11 @@
import "server-only";
import { prisma } from "@formbricks/database";
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { ZEnvironment, ZEnvironmentUpdateInput, ZId } from "@formbricks/types/v1/environment";
import { Prisma, EnvironmentType } from "@prisma/client";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/v1/errors";
import type { TEnvironment, TEnvironmentUpdateInput } from "@formbricks/types/v1/environment";
import type { TEnvironment, TEnvironmentId, TEnvironmentUpdateInput } from "@formbricks/types/v1/environment";
import { populateEnvironment } from "../utils/createDemoProductHelpers";
import { ZEnvironment, ZEnvironmentUpdateInput, ZId } from "@formbricks/types/v1/environment";
import { cache } from "react";
import { validateInputs } from "../utils/validate";
@@ -101,3 +102,87 @@ export const updateEnvironment = async (
throw error;
}
};
export const getEnvironmentByUser = async (user: any): Promise<TEnvironment | TEnvironmentId | null> => {
const firstMembership = await prisma.membership.findFirst({
where: {
userId: user.id,
},
select: {
teamId: true,
},
});
if (!firstMembership) {
// create a new team and return environment
const membership = await prisma.membership.create({
data: {
accepted: true,
role: "owner",
user: { connect: { id: user.id } },
team: {
create: {
name: `${user.name}'s Team`,
products: {
create: {
name: "My Product",
environments: {
create: [
{
type: EnvironmentType.production,
...populateEnvironment,
},
{
type: EnvironmentType.development,
...populateEnvironment,
},
],
},
},
},
},
},
},
include: {
team: {
include: {
products: {
include: {
environments: true,
},
},
},
},
},
});
const environment = membership.team.products[0].environments[0];
return environment;
}
const firstProduct = await prisma.product.findFirst({
where: {
teamId: firstMembership.teamId,
},
select: {
id: true,
},
});
if (firstProduct === null) {
return null;
}
const firstEnvironment = await prisma.environment.findFirst({
where: {
productId: firstProduct.id,
type: "production",
},
select: {
id: true,
},
});
if (firstEnvironment === null) {
return null;
}
return firstEnvironment;
};
+1 -1
View File
@@ -74,7 +74,7 @@ export const updateProduct = async (
productId: string,
inputProduct: Partial<TProductUpdateInput>
): Promise<TProduct> => {
validateInputs([productId, ZId], [inputProduct, ZProductUpdateInput]);
validateInputs([productId, ZId], [inputProduct, ZProductUpdateInput.partial()]);
let updatedProduct;
try {
updatedProduct = await prisma.product.update({
+6 -2
View File
@@ -16,6 +16,7 @@ const responseSelection = {
email: true,
createdAt: true,
updatedAt: true,
onboardingCompleted: true,
};
// function to retrive basic information about a user's profile
@@ -62,8 +63,11 @@ const getAdminMemberships = (memberships: TMembership[]) =>
memberships.filter((membership) => membership.role === MembershipRole.admin);
// function to update a user's profile
export const updateProfile = async (personId: string, data: TProfileUpdateInput): Promise<TProfile> => {
validateInputs([personId, ZId], [data, ZProfileUpdateInput]);
export const updateProfile = async (
personId: string,
data: Partial<TProfileUpdateInput>
): Promise<TProfile> => {
validateInputs([personId, ZId], [data, ZProfileUpdateInput.partial()]);
try {
const updatedProfile = await prisma.user.update({
where: {
+6
View File
@@ -11,6 +11,12 @@ export const ZEnvironment = z.object({
export type TEnvironment = z.infer<typeof ZEnvironment>;
export const ZEnvironmentId = z.object({
id: z.string(),
});
export type TEnvironmentId = z.infer<typeof ZEnvironmentId>;
export const ZEnvironmentUpdateInput = z.object({
type: z.enum(["development", "production"]),
productId: z.string(),
+15 -2
View File
@@ -1,18 +1,31 @@
import z from "zod";
const ZRole = z.enum(["project_manager", "engineer", "founder", "marketing_specialist", "other"]);
const ZObjective = z.enum([
"increase_conversion",
"improve_user_retention",
"increase_user_adoption",
"sharpen_marketing_messaging",
"support_sales",
"other",
]);
export const ZProfile = z.object({
id: z.string(),
name: z.string().nullable(),
email: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
onboardingCompleted: z.boolean(),
});
export type TProfile = z.infer<typeof ZProfile>;
export const ZProfileUpdateInput = z.object({
name: z.string().optional(),
email: z.string().optional(),
name: z.string().nullable(),
email: z.string(),
onboardingCompleted: z.boolean(),
role: ZRole.nullable(),
objective: ZObjective.nullable(),
});
export type TProfileUpdateInput = z.infer<typeof ZProfileUpdateInput>;