feat: Onboarding revamp (#2073)

Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Dhruwang Jariwala
2024-02-29 02:05:10 +05:30
committed by GitHub
parent 06eebe36ee
commit a8563ad905
60 changed files with 1616 additions and 853 deletions
+32 -11
View File
@@ -126,7 +126,9 @@ export const sendInviteMemberEmail = async (
inviteId: string,
email: string,
inviterName: string | null,
inviteeName: string | null
inviteeName: string | null,
isOnboardingInvite?: boolean,
inviteMessage?: string
) => {
const token = createInviteToken(inviteId, email, {
expiresIn: "7d",
@@ -134,16 +136,35 @@ export const sendInviteMemberEmail = async (
const verifyLink = `${WEBAPP_URL}/invite?token=${encodeURIComponent(token)}`;
await sendEmail({
to: email,
subject: `You're invited to collaborate on Formbricks!`,
html: withEmailTemplate(`Hey ${inviteeName},<br/><br/>
Your colleague ${inviterName} invited you to join them at Formbricks. To accept the invitation, please click the link below:<br/><br/>
<a class="button" href="${verifyLink}">Join team</a><br/>
<br/>
Have a great day!<br/>
The Formbricks Team!`),
});
if (isOnboardingInvite && inviteMessage) {
await sendEmail({
to: email,
subject: `${inviterName} needs a hand setting up Formbricks. Can you help out?`,
html: withEmailTemplate(`Hey 👋,<br/><br/>
${inviteMessage}
<h2>Get Started in Minutes</h2>
<ol>
<li>Create an account to join ${inviterName}'s team.</li>
<li>Connect Formbricks to your app or website via HTML Snippet or NPM in just a few minutes.</li>
<li>Done ✅</li>
</ol>
<a class="button" href="${verifyLink}">Join ${inviterName}'s team</a><br/>
<br/>
Have a great day!<br/>
The Formbricks Team!`),
});
} else {
await sendEmail({
to: email,
subject: `You're invited to collaborate on Formbricks!`,
html: withEmailTemplate(`Hey ${inviteeName},<br/><br/>
Your colleague ${inviterName} invited you to join them at Formbricks. To accept the invitation, please click the link below:<br/><br/>
<a class="button" href="${verifyLink}">Join team</a><br/>
<br/>
Have a great day!<br/>
The Formbricks Team!`),
});
}
};
export const sendInviteAcceptedEmail = async (inviterName: string, inviteeName: string, email: string) => {
+5 -1
View File
@@ -194,10 +194,14 @@ export const inviteUser = async ({
currentUser,
invitee,
teamId,
isOnboardingInvite,
inviteMessage,
}: {
teamId: string;
invitee: TInvitee;
currentUser: TCurrentUser;
isOnboardingInvite?: boolean;
inviteMessage?: string;
}): Promise<TInvite> => {
validateInputs([teamId, ZString], [invitee, ZInvitee], [currentUser, ZCurrentUser]);
@@ -239,6 +243,6 @@ export const inviteUser = async ({
teamId: invite.teamId,
});
await sendInviteMemberEmail(invite.id, email, currentUserName, name);
await sendInviteMemberEmail(invite.id, email, currentUserName, name, isOnboardingInvite, inviteMessage);
return invite;
};
+55 -48
View File
@@ -31,6 +31,15 @@ import { validateInputs } from "../utils/validate";
import { surveyCache } from "./cache";
import { anySurveyHasFilters } from "./util";
interface TriggerUpdate {
create?: Array<{ actionClassId: string }>;
deleteMany?: {
actionClassId: {
in: string[];
};
};
}
export const selectSurvey = {
id: true,
createdAt: true,
@@ -100,6 +109,47 @@ const revalidateSurveyByActionClassId = (actionClasses: TActionClass[], actionCl
}
};
const processTriggerUpdates = (
triggers: string[],
currentSurveyTriggers: string[],
actionClasses: TActionClass[]
) => {
const newTriggers: string[] = [];
const removedTriggers: string[] = [];
// find added triggers
for (const trigger of triggers) {
if (!trigger || currentSurveyTriggers.includes(trigger)) {
continue;
}
newTriggers.push(trigger);
}
// find removed triggers
for (const trigger of currentSurveyTriggers) {
if (!triggers.includes(trigger)) {
removedTriggers.push(getActionClassIdFromName(actionClasses, trigger));
}
}
// Construct the triggers update object
const triggersUpdate: TriggerUpdate = {};
if (newTriggers.length > 0) {
triggersUpdate.create = newTriggers.map((trigger) => ({
actionClassId: getActionClassIdFromName(actionClasses, trigger),
}));
}
if (removedTriggers.length > 0) {
triggersUpdate.deleteMany = {
actionClassId: { in: removedTriggers },
};
}
revalidateSurveyByActionClassId(actionClasses, [...newTriggers, ...removedTriggers]);
return triggersUpdate;
};
export const getSurvey = async (surveyId: string): Promise<TSurvey | null> => {
const survey = await unstable_cache(
async () => {
@@ -281,52 +331,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
const { triggers, environmentId, segment, ...surveyData } = updatedSurvey;
if (triggers) {
const newTriggers: string[] = [];
const removedTriggers: string[] = [];
// find added triggers
for (const trigger of triggers) {
if (!trigger) {
continue;
}
if (currentSurvey.triggers.find((t) => t === trigger)) {
continue;
} else {
newTriggers.push(trigger);
}
}
// find removed triggers
for (const trigger of currentSurvey.triggers) {
if (triggers.find((t: any) => t === trigger)) {
continue;
} else {
removedTriggers.push(trigger);
}
}
// create new triggers
if (newTriggers.length > 0) {
data.triggers = {
...(data.triggers || []),
create: newTriggers.map((trigger) => ({
actionClassId: getActionClassIdFromName(actionClasses, trigger),
})),
};
}
// delete removed triggers
if (removedTriggers.length > 0) {
data.triggers = {
...(data.triggers || []),
deleteMany: {
actionClassId: {
in: removedTriggers.map((trigger) => getActionClassIdFromName(actionClasses, trigger)),
},
},
};
}
// Revalidation for newly added/removed actionClassId
revalidateSurveyByActionClassId(actionClasses, [...newTriggers, ...removedTriggers]);
data.triggers = processTriggerUpdates(triggers, currentSurvey.triggers, actionClasses);
}
if (segment) {
@@ -441,8 +446,10 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp
const data: Omit<Prisma.SurveyCreateInput, "environment"> = {
...surveyBody,
// TODO: Create with triggers & attributeFilters
triggers: undefined,
// TODO: Create with attributeFilters
triggers: surveyBody.triggers
? processTriggerUpdates(surveyBody.triggers, [], await getActionClasses(environmentId))
: undefined,
attributeFilters: undefined,
};
+2 -1
View File
@@ -3,7 +3,7 @@ import { prisma } from "../../__mocks__/database";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, it } from "vitest";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { testInputValidation } from "../../vitestSetup";
import {
@@ -204,6 +204,7 @@ describe("Tests for createSurvey", () => {
it("Creates a survey successfully", async () => {
prisma.survey.create.mockResolvedValueOnce(mockSurveyOutput);
prisma.team.findFirst.mockResolvedValueOnce(mockTeamOutput);
prisma.actionClass.findMany.mockResolvedValue([mockActionClass]);
prisma.user.findMany.mockResolvedValueOnce([
{
...mockUser,
+5 -5
View File
@@ -6,13 +6,13 @@
"downlevelIteration": true,
"baseUrl": ".",
"paths": {
"@prisma/client/*": ["@formbricks/database/client/*"]
"@prisma/client/*": ["@formbricks/database/client/*"],
},
"plugins": [
{
"name": "next"
}
"name": "next",
},
],
"strictNullChecks": true
}
"strictNullChecks": true,
},
}
+1 -4
View File
@@ -168,8 +168,6 @@ export const createUser = async (data: TUserCreateInput): Promise<TUser> => {
select: responseSelection,
});
console.log("user", user);
userCache.revalidate({
email: user.email,
id: user.id,
@@ -210,6 +208,7 @@ export const deleteUser = async (id: string): Promise<TUser> => {
const teamHasAtLeastOneAdmin = teamAdminMemberships.length > 0;
const teamHasOnlyOneMember = teamMemberships.length === 1;
const currentUserIsTeamOwner = role === "owner";
await deleteMembership(id, teamId);
if (teamHasOnlyOneMember) {
await deleteTeam(teamId);
@@ -219,8 +218,6 @@ export const deleteUser = async (id: string): Promise<TUser> => {
} else if (currentUserIsTeamOwner) {
await deleteTeam(teamId);
}
await deleteMembership(id, teamId);
}
const deletedUser = await deleteUserById(id);
@@ -150,6 +150,9 @@ export function Survey({
const finished = nextQuestionId === "end";
onResponse({ data: responseData, ttc, finished });
if (finished) {
// Dispatching a custom event when the survey is completed
const event = new CustomEvent("formbricksSurveyCompleted", { detail: { surveyId: survey.id } });
window.top?.dispatchEvent(event);
onFinished();
}
setQuestionId(nextQuestionId);
@@ -75,7 +75,7 @@ export default function ThankYouCard({
isLastQuestion={false}
onClick={() => {
if (!buttonLink) return;
window.location.href = buttonLink;
window.location.replace(buttonLink);
}}
/>
<p class="text-xs">Press Enter </p>
@@ -21,6 +21,11 @@ module.exports = {
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
},
boxShadow: {
"card-sm": "0px 0.5px 12px -5px rgba(30,41,59,0.20)",
"card-md": "0px 1px 25px -10px rgba(30,41,59,0.30)",
"card-lg": "0px 2px 51px -19px rgba(30,41,59,0.40)",
},
colors: {
brand: {
DEFAULT: "#00E6CA",
+1 -1
View File
@@ -391,7 +391,7 @@ export const ZSurveyQuestions = z.array(ZSurveyQuestion);
export type TSurveyQuestions = z.infer<typeof ZSurveyQuestions>;
const ZSurveyDisplayOption = z.enum(["displayOnce", "displayMultiple", "respondMultiple"]);
export const ZSurveyDisplayOption = z.enum(["displayOnce", "displayMultiple", "respondMultiple"]);
export type TSurveyDisplayOption = z.infer<typeof ZSurveyDisplayOption>;
+15 -9
View File
@@ -8,16 +8,20 @@ import toast from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import "./style.css";
interface CodeBlockProps {
children: React.ReactNode;
language: string;
customCodeClass?: string;
customEditorClass?: string;
showCopyToClipboard?: boolean;
}
const CodeBlock: React.FC<CodeBlockProps> = ({
children,
language,
customEditorClass = "",
customCodeClass = "",
showCopyToClipboard = true,
}) => {
@@ -28,16 +32,18 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
return (
<div className="group relative mt-4 rounded-md text-sm text-slate-200">
{showCopyToClipboard && (
<DocumentDuplicateIcon
className="absolute right-4 top-4 z-20 h-5 w-5 cursor-pointer text-slate-600 opacity-0 transition-all duration-150 group-hover:opacity-60"
onClick={() => {
const childText = children?.toString() || "";
navigator.clipboard.writeText(childText);
toast.success("Copied to clipboard");
}}
/>
<div className="absolute right-2 top-2 z-20 h-8 w-8 cursor-pointer rounded-md bg-slate-100 p-1.5 text-slate-600 hover:bg-slate-200">
<DocumentDuplicateIcon
className=""
onClick={() => {
const childText = children?.toString() || "";
navigator.clipboard.writeText(childText);
toast.success("Copied to clipboard");
}}
/>
</div>
)}
<pre>
<pre className={customEditorClass}>
<code className={cn(`language-${language} whitespace-pre-wrap`, customCodeClass)}>{children}</code>
</pre>
</div>
+21
View File
@@ -0,0 +1,21 @@
pre {
scrollbar-width: thin;
scrollbar-color: #e2e8f0 #ffffff;
}
pre::-webkit-scrollbar {
width: 4px !important;
border-radius: 99px;
}
pre::-webkit-scrollbar-track {
background: #e2e8f0;
border-radius: 99px;
}
pre::-webkit-scrollbar-thumb {
background-color: #cbd5e1;
border: 3px solid #cbd5e1;
border-radius: 99px;
}
+48
View File
@@ -0,0 +1,48 @@
import React from "react";
import LoadingSpinner from "../LoadingSpinner";
interface PathwayOptionProps {
size: "sm" | "md" | "lg";
title: string;
description: string;
loading?: boolean;
onSelect: () => void;
children?: React.ReactNode;
}
const sizeClasses = {
sm: "rounded-lg border border-slate-200 shadow-card-sm transition-all duration-150",
md: "rounded-xl border border-slate-200 shadow-card-md transition-all duration-300",
lg: "rounded-2xl border border-slate-200 shadow-card-lg transition-all duration-500",
};
export const OptionCard: React.FC<PathwayOptionProps> = ({
size,
title,
description,
children,
onSelect,
loading,
}) => (
<div className="relative">
<div
className={`flex cursor-pointer flex-col items-center justify-center bg-white p-4 hover:scale-105 hover:border-slate-300 ${sizeClasses[size]}`}
onClick={onSelect}
role="button"
tabIndex={0}>
<div className="space-y-4">
{children}
<div className="space-y-2">
<p className="text-xl font-medium text-slate-800">{title}</p>
<p className="text-sm text-slate-500">{description}</p>
</div>
</div>
</div>
{loading && (
<div className="absolute inset-0 flex h-full w-full items-center justify-center bg-slate-100 opacity-50">
<LoadingSpinner />
</div>
)}
</div>
);
+1 -1
View File
@@ -13,7 +13,7 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({ progress, barColor, he
<div className={cn(height === 2 ? "h-2" : height === 5 ? "h-5" : "", "w-full rounded-full bg-slate-200")}>
<div
className={cn("h-full rounded-full", barColor)}
style={{ width: `${Math.floor(progress * 100)}%` }}></div>
style={{ width: `${Math.floor(progress * 100)}%`, transition: "width 0.5s ease-out" }}></div>
</div>
);
};
+2 -4
View File
@@ -30,14 +30,12 @@ export default function SurveysList({
const [filteredSurveys, setFilteredSurveys] = useState<TSurvey[]>(surveys);
// Initialize orientation state with a function that checks if window is defined
const [orientation, setOrientation] = useState(() =>
typeof window !== "undefined" ? localStorage.getItem("surveyOrientation") || "grid" : "grid"
typeof localStorage !== "undefined" ? localStorage.getItem("surveyOrientation") || "grid" : "grid"
);
// Save orientation to localStorage
useEffect(() => {
if (typeof window !== "undefined") {
localStorage.setItem("surveyOrientation", orientation);
}
localStorage.setItem("surveyOrientation", orientation);
}, [orientation]);
return (
+2 -2
View File
@@ -3,6 +3,6 @@
"include": [".", "../types/*.d.ts"],
"exclude": ["build", "node_modules"],
"compilerOptions": {
"lib": ["ES2021.String"]
}
"lib": ["ES2021.String"],
},
}