mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-28 12:42:44 -05:00
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:
committed by
GitHub
parent
06eebe36ee
commit
a8563ad905
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"include": [".", "../types/*.d.ts"],
|
||||
"exclude": ["build", "node_modules"],
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2021.String"]
|
||||
}
|
||||
"lib": ["ES2021.String"],
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user