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

View File

@@ -12,8 +12,8 @@
// Configure properties specific to VS Code.
"vscode": {
// Add the IDs of extensions you want installed when the container is created.
"extensions": ["dbaeumer.vscode-eslint"]
}
"extensions": ["dbaeumer.vscode-eslint"],
},
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
@@ -25,5 +25,5 @@
"postAttachCommand": "pnpm dev --filter=web... --filter=demo...",
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "node"
"remoteUser": "node",
}

View File

@@ -52,7 +52,7 @@ All you need to do is copy a `<script>` tag to your HTML head, and thats abou
```
</CodeGroup>
</Col>
### Required Customizations to be Made
### Required customizations to be made
<Properties>
<Property name="environment-id" type="string">
@@ -112,7 +112,7 @@ export default App;
</CodeGroup>
</Col>
### Required Customizations to be Made
### Required customizations to be made
<Properties>
<Property name="environment-id" type="string">
@@ -256,7 +256,7 @@ export default function App({ Component, pageProps }: AppProps) {
</Col>
Refer to our [Example NextJS Pages Directory project](https://github.com/formbricks/examples/tree/main/nextjs-pages) for more help!
### Required Customizations to be Made
### Required customizations to be made
<Properties>
<Property name="environment-id" type="string">
@@ -345,7 +345,7 @@ router.afterEach((to, from) => {
</CodeGroup>
</Col>
### Required Customizations to be Made
### Required customizations to be made
<Properties>
<Property name="environment-id" type="string">

View File

@@ -66,6 +66,7 @@ export default function AddProductModal({ environmentId, open, setOpen }: AddPro
<div>
<Label>Name</Label>
<Input
autoFocus
placeholder="e.g. My New Product"
{...register("name", { required: true })}
value={productName}

View File

@@ -61,7 +61,7 @@ function DeleteAccountModal({ setOpen, open, session, IS_FORMBRICKS_CLOUD }: Del
await signOut({ redirect: true });
window.location.replace("https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2");
} else {
await signOut();
await signOut({ callbackUrl: "/auth/login" });
}
} catch (error) {
toast.error("Something went wrong");

View File

@@ -27,6 +27,7 @@ export default function SurveyStarter({
}) {
const [isCreateSurveyLoading, setIsCreateSurveyLoading] = useState(false);
const router = useRouter();
const newSurveyFromTemplate = async (template: TTemplate) => {
setIsCreateSurveyLoading(true);
const surveyType = environment?.widgetSetupCompleted ? "web" : "link";

View File

@@ -2,8 +2,12 @@ import { createId } from "@paralleldrive/cuid2";
import {
TSurvey,
TSurveyCTAQuestion,
TSurveyDisplayOption,
TSurveyHiddenFields,
TSurveyQuestionType,
TSurveyStatus,
TSurveyType,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys";
import { TTemplate } from "@formbricks/types/templates";
@@ -558,7 +562,6 @@ export const templates: TTemplate[] = [
},
{
name: "Churn Survey",
category: "Increase Revenue",
objectives: ["sharpen_marketing_messaging", "improve_user_retention"],
description: "Find out why people cancel their subscriptions. These insights are pure gold!",
@@ -1125,7 +1128,7 @@ export const templates: TTemplate[] = [
},
},
{
name: "Changing subscription experience",
name: "Changing Subscription Experience",
category: "Increase Revenue",
objectives: ["increase_conversion", "improve_user_retention"],
@@ -1519,9 +1522,9 @@ export const templates: TTemplate[] = [
category: "Customer Success",
objectives: ["support_sales"],
description: "Measure the Net Promoter Score of your product.",
description: "Measure the Net Promoter Score of your product or service.",
preset: {
name: "{{productName}} NPS",
name: "NPS Survey",
welcomeCard: welcomeCardDefault,
questions: [
{
@@ -1546,7 +1549,6 @@ export const templates: TTemplate[] = [
},
{
name: "Customer Satisfaction Score (CSAT)",
category: "Customer Success",
objectives: ["support_sales"],
description: "Measure the Customer Satisfaction Score of your product.",
@@ -1589,7 +1591,95 @@ export const templates: TTemplate[] = [
},
},
{
name: "Identify upsell opportunities",
name: "Collect Feedback",
category: "Product Experience",
objectives: ["increase_user_adoption", "improve_user_retention"],
description: "Gather comprehensive feedback on your product or service.",
preset: {
name: "Feedback Survey",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
type: TSurveyQuestionType.Rating,
logic: [{ value: "3", condition: "lessEqual", destination: "dlpa0371pe7rphmggy2sgbap" }],
range: 5,
scale: "star",
headline: "How do you rate your overall experience?",
required: true,
subheader: "Don't worry, be honest.",
lowerLabel: "Not good",
upperLabel: "Very good",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
logic: [{ condition: "submitted", destination: "gwo0fq5kug13e83fcour4n1w" }],
headline: "Lovely! What did you like about it?",
required: true,
longAnswer: true,
placeholder: "Type your answer here...",
inputType: "text",
},
{
id: "dlpa0371pe7rphmggy2sgbap",
type: TSurveyQuestionType.OpenText,
headline: "Thanks for sharing! What did you not like?",
required: true,
longAnswer: true,
placeholder: "Type your answer here...",
inputType: "text",
},
{
id: "gwo0fq5kug13e83fcour4n1w",
type: TSurveyQuestionType.Rating,
range: 5,
scale: "smiley",
headline: "How do you rate our communication?",
required: true,
lowerLabel: "Not good",
upperLabel: "Very good",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "Anything else you'd like to share with our team?",
required: false,
longAnswer: true,
placeholder: "Type your answer here...",
inputType: "text",
},
{
id: "sjbaghd1bi59pkjun2c97kw9",
type: TSurveyQuestionType.MultipleChoiceSingle,
logic: [],
choices: [
{ id: createId(), label: "Google" },
{ id: createId(), label: "Social Media" },
{ id: createId(), label: "Friends" },
{ id: createId(), label: "Podcast" },
{ id: "other", label: "Other" },
],
headline: "How did you hear about us?",
required: true,
shuffleOption: "none",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "Lastly, we'd love to respond to your feedback. Please share your email:",
required: false,
inputType: "email",
longAnswer: false,
placeholder: "example@email.com",
},
],
thankYouCard: thankYouCardDefault,
hiddenFields: hiddenFieldsDefault,
},
},
{
name: "Identify Upsell Opportunities",
category: "Increase Revenue",
objectives: ["support_sales", "sharpen_marketing_messaging"],
@@ -2554,3 +2644,26 @@ export const minimalSurvey: TSurvey = {
resultShareKey: null,
segment: null,
};
export const getFirstSurvey = (webAppUrl: string) => ({
...customSurvey.preset,
questions: customSurvey.preset.questions.map(
(question) =>
({
...question,
type: TSurveyQuestionType.CTA,
headline: "You did it 🎉",
html: "You're all set up. Create your own survey to gather exactly the feedback you need :)",
buttonLabel: "Create survey",
buttonExternal: true,
imageUrl: `${webAppUrl}/onboarding/meme.png`,
}) as TSurveyCTAQuestion
),
name: "Example survey",
type: "web" as TSurveyType,
autoComplete: 2,
triggers: ["New Session"],
status: "inProgress" as TSurveyStatus,
displayOption: "respondMultiple" as TSurveyDisplayOption,
recontactDays: 0,
});

View File

@@ -2,14 +2,111 @@
import { getServerSession } from "next-auth";
import { hasTeamAuthority } from "@formbricks/lib/auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { canUserAccessProduct, verifyUserRoleAccess } from "@formbricks/lib/product/auth";
import { INVITE_DISABLED } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { inviteUser } from "@formbricks/lib/invite/service";
import { canUserAccessProduct } from "@formbricks/lib/product/auth";
import { getProduct, updateProduct } from "@formbricks/lib/product/service";
import { createSurvey } from "@formbricks/lib/survey/service";
import { verifyUserRoleAccess } from "@formbricks/lib/team/auth";
import { updateUser } from "@formbricks/lib/user/service";
import { AuthorizationError } from "@formbricks/types/errors";
import { AuthenticationError, AuthorizationError } from "@formbricks/types/errors";
import { TMembershipRole } from "@formbricks/types/memberships";
import { TProductUpdateInput } from "@formbricks/types/product";
import { TSurveyInput, TSurveyType } from "@formbricks/types/surveys";
import { TTemplate } from "@formbricks/types/templates";
import { TUserUpdateInput } from "@formbricks/types/user";
export const inviteTeamMateAction = async (
teamId: string,
email: string,
role: TMembershipRole,
inviteMessage: string
) => {
const session = await getServerSession(authOptions);
if (!session) {
throw new AuthenticationError("Not authenticated");
}
const isUserAuthorized = await hasTeamAuthority(session.user.id, teamId);
if (INVITE_DISABLED) {
throw new AuthenticationError("Invite disabled");
}
if (!isUserAuthorized) {
throw new AuthenticationError("Not authorized");
}
const { hasCreateOrUpdateMembersAccess } = await verifyUserRoleAccess(teamId, session.user.id);
if (!hasCreateOrUpdateMembersAccess) {
throw new AuthenticationError("Not authorized");
}
const invite = await inviteUser({
teamId,
currentUser: { id: session.user.id, name: session.user.name },
invitee: {
email,
name: "",
role,
},
isOnboardingInvite: true,
inviteMessage: inviteMessage,
});
return invite;
};
export const finishOnboardingAction = async () => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const updatedProfile = { onboardingCompleted: true };
return await updateUser(session.user.id, updatedProfile);
};
export async function createSurveyAction(environmentId: string, surveyBody: TSurveyInput) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await createSurvey(environmentId, surveyBody);
}
export async function fetchEnvironment(id: string) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
return await getEnvironment(id);
}
export const createSurveyFromTemplate = async (template: TTemplate, environmentId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const userHasAccess = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!userHasAccess) throw new AuthorizationError("Not authorized");
// Set common survey properties
const userId = session.user.id;
// Construct survey input based on the pathway
const surveyInput = {
...template.preset,
type: "link" as TSurveyType,
autoComplete: undefined,
createdBy: userId,
};
// Create and return the new survey
return await createSurvey(environmentId, surveyInput);
};
export async function updateUserAction(updatedUser: TUserUpdateInput) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");

View File

@@ -1,76 +0,0 @@
"use client";
import type { Session } from "next-auth";
import Link from "next/link";
import { useEffect, useRef } from "react";
import { Button } from "@formbricks/ui/Button";
type Greeting = {
next: () => void;
skip: () => void;
name: string;
session: Session | null;
};
const Greeting: React.FC<Greeting> = ({ next, skip, name, session }) => {
const legacyUser = !session ? false : new Date(session?.user?.createdAt) < new Date("2023-05-03T00:00:00"); // if user is created before onboarding deployment
const buttonRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
next();
}
};
const button = buttonRef.current;
if (button) {
button.focus();
button.addEventListener("keydown", handleKeyDown);
}
return () => {
if (button) {
button.removeEventListener("keydown", handleKeyDown);
}
};
}, [next]);
return (
<div className="flex h-full w-full max-w-xl flex-col justify-around gap-8 px-8">
<div className="mt-auto h-1/2 space-y-6">
<div className="px-4">
<h1 className="pb-4 text-4xl font-bold text-slate-900">
👋 Hi, {name}! <br />
{legacyUser ? "Welcome back!" : "Welcome to Formbricks!"}
</h1>
<p className="text-xl text-slate-500">
{legacyUser ? "Let's customize your account." : "Let's finish setting up your account."}
</p>
</div>
<div className="flex justify-between">
<Button size="lg" variant="minimal" onClick={skip}>
I&apos;ll do it later
</Button>
<Button size="lg" variant="darkCTA" onClick={next} ref={buttonRef} tabIndex={0}>
Begin (1 min)
</Button>
</div>
</div>
<div className="flex items-center justify-center text-xs text-slate-400">
<div className="pb-12 pt-8 text-center">
<p>Your answers will help us improve your experience and help others like you.</p>
<p>
<Link href="https://formbricks.com/privacy-policy" target="_blank" className="underline">
Click here
</Link>{" "}
to learn how we handle your data.
</p>
</div>
</div>
</div>
);
};
export default Greeting;

View File

@@ -1,150 +0,0 @@
"use client";
import { updateUserAction } from "@/app/(app)/onboarding/actions";
import { formbricksEnabled, updateResponse } from "@/app/lib/formbricks";
import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { env } from "@formbricks/lib/env";
import { TUser, TUserObjective } from "@formbricks/types/user";
import { Button } from "@formbricks/ui/Button";
import { handleTabNavigation } from "../utils";
type ObjectiveProps = {
next: () => void;
skip: () => void;
formbricksResponseId?: string;
user: TUser;
};
type ObjectiveChoice = {
label: string;
id: TUserObjective;
};
const Objective: React.FC<ObjectiveProps> = ({ next, skip, formbricksResponseId, user }) => {
const objectives: Array<ObjectiveChoice> = [
{ label: "Increase conversion", id: "increase_conversion" },
{ label: "Improve user retention", id: "improve_user_retention" },
{ label: "Increase user adoption", id: "increase_user_adoption" },
{ label: "Sharpen marketing messaging", id: "sharpen_marketing_messaging" },
{ label: "Support sales", id: "support_sales" },
{ label: "Other", id: "other" },
];
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const [isProfileUpdating, setIsProfileUpdating] = useState(false);
const fieldsetRef = useRef<HTMLFieldSetElement>(null);
useEffect(() => {
const onKeyDown = handleTabNavigation(fieldsetRef, setSelectedChoice);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [fieldsetRef, setSelectedChoice]);
const handleNextClick = async () => {
if (selectedChoice) {
const selectedObjective = objectives.find((objective) => objective.label === selectedChoice);
if (selectedObjective) {
try {
setIsProfileUpdating(true);
await updateUserAction({
objective: selectedObjective.id,
name: user.name ?? undefined,
});
setIsProfileUpdating(false);
} catch (e) {
setIsProfileUpdating(false);
console.error(e);
toast.error("An error occured saving your settings");
}
if (formbricksEnabled && env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID && formbricksResponseId) {
const res = await updateResponse(
formbricksResponseId,
{
objective: selectedObjective.label,
},
true
);
if (!res.ok) {
console.error("Error updating response", res.error);
}
}
next();
}
}
};
return (
<div className="flex w-full max-w-xl flex-col gap-8 px-8">
<div className="px-4">
<label htmlFor="choices" className="mb-1.5 block text-base font-semibold leading-6 text-slate-900">
What do you want to achieve?
</label>
<label className="block text-sm font-normal leading-6 text-slate-500">
We have 85+ templates, help us select the best for your need.
</label>
<div className="mt-4">
<fieldset id="choices" aria-label="What do you want to achieve?" ref={fieldsetRef}>
<legend className="sr-only">Choices</legend>
<div className=" relative space-y-2 rounded-md">
{objectives.map((choice) => (
<label
key={choice.id}
className={cn(
selectedChoice === choice.label
? "z-10 border-slate-400 bg-slate-100"
: "border-slate-200",
"relative flex cursor-pointer flex-col rounded-md border p-4 hover:bg-slate-100 focus:outline-none"
)}>
<span className="flex items-center text-sm">
<input
type="radio"
id={choice.id}
value={choice.label}
checked={choice.label === selectedChoice}
className="checked:text-brand-dark focus:text-brand-dark h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={(e) => {
setSelectedChoice(e.currentTarget.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleNextClick();
}
}}
/>
<span id={`${choice.id}-label`} className="ml-3 font-medium">
{choice.label}
</span>
</span>
</label>
))}
</div>
</fieldset>
</div>
</div>
<div className="mb-24 flex justify-between">
<Button size="lg" className="text-slate-500" variant="minimal" onClick={skip} id="objective-skip">
Skip
</Button>
<Button
size="lg"
variant="darkCTA"
loading={isProfileUpdating}
disabled={!selectedChoice}
onClick={handleNextClick}
id="objective-next">
Next
</Button>
</div>
</div>
);
};
export default Objective;

View File

@@ -1,109 +0,0 @@
"use client";
import { updateUserAction } from "@/app/(app)/onboarding/actions";
import { Session } from "next-auth";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { TProduct } from "@formbricks/types/product";
import { TUser } from "@formbricks/types/user";
import { Logo } from "@formbricks/ui/Logo";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
import Greeting from "./Greeting";
import Objective from "./Objective";
import Product from "./Product";
import Role from "./Role";
const MAX_STEPS = 6;
interface OnboardingProps {
session: Session;
environmentId: string;
user: TUser;
product: TProduct;
}
export default function Onboarding({ session, environmentId, user, product }: OnboardingProps) {
const [formbricksResponseId, setFormbricksResponseId] = useState<string | undefined>();
const [currentStep, setCurrentStep] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const percent = useMemo(() => {
return currentStep / MAX_STEPS;
}, [currentStep]);
const skipStep = () => {
setCurrentStep(currentStep + 1);
};
const doLater = async () => {
setCurrentStep(4);
};
const next = () => {
if (currentStep < MAX_STEPS) {
setCurrentStep((value) => value + 1);
return;
}
};
const done = async () => {
setIsLoading(true);
try {
const updatedProfile = { onboardingCompleted: true };
await updateUserAction(updatedProfile);
if (environmentId) {
router.push(`/environments/${environmentId}/surveys`);
return;
}
} catch (e) {
toast.error("An error occured saving your settings.");
setIsLoading(false);
console.error(e);
}
};
return (
<div className="flex h-full w-full flex-col bg-slate-50">
<div className="mx-auto grid w-full max-w-7xl grid-cols-6 items-center pt-8">
<div className="col-span-2">
<Logo className="ml-4 w-1/2" />
</div>
<div className="col-span-2 flex items-center justify-center gap-8">
<div className="relative grow overflow-hidden rounded-full bg-slate-200">
<ProgressBar progress={percent} barColor="bg-brand-dark" height={2} />
</div>
<div className="grow-0 text-xs font-semibold text-slate-700">
{currentStep < 5 ? <>{Math.floor(percent * 100)}% complete</> : <>Almost there!</>}
</div>
</div>
<div className="col-span-2" />
</div>
<div className="flex grow items-center justify-center">
{currentStep === 1 && (
<Greeting next={next} skip={doLater} name={user.name ? user.name : ""} session={session} />
)}
{currentStep === 2 && (
<Role
next={next}
skip={skipStep}
setFormbricksResponseId={setFormbricksResponseId}
session={session}
/>
)}
{currentStep === 3 && (
<Objective next={next} skip={skipStep} formbricksResponseId={formbricksResponseId} user={user} />
)}
{currentStep === 4 && (
<Product done={done} environmentId={environmentId} isLoading={isLoading} product={product} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
// Filename: IntroSection.tsx
import React from "react";
type OnboardingTitleProps = {
title: string;
subtitle: string;
};
const OnboardingTitle: React.FC<OnboardingTitleProps> = ({ title, subtitle }) => {
return (
<div className="space-y-4 text-center">
<p className="text-4xl font-medium text-slate-800">{title}</p>
<p className="text-sm text-slate-500">{subtitle}</p>
</div>
);
};
export default OnboardingTitle;

View File

@@ -0,0 +1,67 @@
import OnboardingTitle from "@/app/(app)/onboarding/components/OnboardingTitle";
import InappMockup from "@/images/onboarding-in-app-survey.png";
import LinkMockup from "@/images/onboarding-link-survey.webp";
import Image from "next/image";
import { OptionCard } from "@formbricks/ui/OptionCard";
interface PathwaySelectProps {
setSelectedPathway: (pathway: "link" | "in-app" | null) => void;
setCurrentStep: (currentStep: number) => void;
isFormbricksCloud: boolean;
}
type PathwayOptionType = "link" | "in-app";
export default function PathwaySelect({
setSelectedPathway,
setCurrentStep,
isFormbricksCloud,
}: PathwaySelectProps) {
const handleSelect = async (pathway: PathwayOptionType) => {
if (pathway === "link") {
localStorage.setItem("onboardingPathway", "link");
if (isFormbricksCloud) {
setCurrentStep(2);
localStorage.setItem("onboardingCurrentStep", "2");
} else {
setCurrentStep(5);
localStorage.setItem("onboardingCurrentStep", "5");
}
} else {
localStorage.setItem("onboardingPathway", "in-app");
setCurrentStep(2);
localStorage.setItem("onboardingCurrentStep", "2");
}
setSelectedPathway(pathway);
};
return (
<div className="space-y-16 text-center">
<OnboardingTitle
title="How would you like to start?"
subtitle="You can always use all types of surveys later on."
/>
<div className="flex space-x-8">
<OptionCard
size="lg"
title="Link Surveys"
description="Create a new survey and share a link."
onSelect={() => {
handleSelect("link");
}}>
<Image src={LinkMockup} alt="" height={350} />
</OptionCard>
<OptionCard
size="lg"
title="In-app Surveys"
description="Run a survey on a website or in-app."
onSelect={() => {
handleSelect("in-app");
}}>
<Image src={InappMockup} alt="" height={350} />
</OptionCard>
</div>
</div>
);
}

View File

@@ -1,174 +0,0 @@
"use client";
import { updateProductAction } from "@/app/(app)/onboarding/actions";
import { isLight } from "@/app/lib/utils";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { TProduct } from "@formbricks/types/product";
import { Button } from "@formbricks/ui/Button";
import { ColorPicker } from "@formbricks/ui/ColorPicker";
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import LoadingSpinner from "@formbricks/ui/LoadingSpinner";
type Product = {
done: () => void;
environmentId: string;
isLoading: boolean;
product: TProduct;
};
const Product: React.FC<Product> = ({ done, isLoading, environmentId, product }) => {
const [loading, setLoading] = useState(true);
const [name, setName] = useState("");
const [color, setColor] = useState("##4748b");
const handleNameChange = (event) => {
setName(event.target.value);
};
const handleColorChange = (color) => {
setColor(color);
};
useEffect(() => {
if (!product) {
return;
} else if (product && product.name !== "My Product") {
done(); // when product already exists, skip product step entirely
} else {
if (product) {
setColor(product.brandColor);
}
setLoading(false);
}
}, [product, done]);
const dummyChoices = ["❤️ Love it!"];
const handleDoneClick = async () => {
if (!name || !environmentId) {
return;
}
try {
await updateProductAction(product.id, { name, brandColor: color });
} catch (e) {
toast.error("An error occured saving your settings");
console.error(e);
}
done();
};
const handleLaterClick = async () => {
done();
};
if (loading) {
return <LoadingSpinner />;
}
if (!product) {
return <ErrorComponent />;
}
const buttonStyle = {
backgroundColor: color,
color: isLight(color) ? "black" : "white",
};
return (
<div className="flex w-full max-w-xl flex-col gap-8 px-8">
<div className="px-4">
<label className="mb-1.5 block text-base font-semibold leading-6 text-slate-900">
Create your team&apos;s product.
</label>
<label className="block text-sm font-normal leading-6 text-slate-500">
You can always change these settings later.
</label>
<div className="mt-6 flex flex-col gap-2">
<div className="pb-2">
<div className="flex justify-between">
<Label htmlFor="product">Your product name</Label>
<span className="text-xs text-slate-500">Required</span>
</div>
<div className="mt-2">
<Input
id="product"
type="text"
placeholder="e.g. Formbricks"
value={name}
onChange={handleNameChange}
aria-label="Your product name"
/>
</div>
</div>
<div>
<Label htmlFor="color">Primary color</Label>
<div className="mt-2">
<ColorPicker color={color} onChange={handleColorChange} />
</div>
</div>
<div className="relative flex cursor-not-allowed flex-col items-center gap-4 rounded-md border border-slate-300 px-16 py-8">
<div
className="absolute left-0 right-0 top-0 h-full w-full opacity-10"
style={{ backgroundColor: color }}
/>
<p className="text-xs text-slate-500">This is what your survey will look like:</p>
<div className="relative w-full max-w-sm cursor-not-allowed rounded-lg bg-white px-4 py-6 shadow-lg ring-1 ring-black ring-opacity-5 sm:p-6">
<label className="mb-1.5 block text-base font-semibold leading-6 text-slate-900">
How do you like {name || "Formbricks"}
</label>
<div className="mt-4">
<fieldset>
<legend className="sr-only">Choices</legend>
<div className=" relative space-y-2 rounded-md">
{dummyChoices.map((choice) => (
<label
key={choice}
className="relative z-10 flex flex-col rounded-md border border-slate-400 bg-slate-50 p-4 hover:bg-slate-50 focus:outline-none">
<span className="flex items-center text-sm">
<input
checked
readOnly
type="radio"
className="h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
style={{ borderColor: "brandColor", color: "brandColor" }}
/>
<span className="ml-3 font-medium">{choice}</span>
</span>
</label>
))}
</div>
</fieldset>
</div>
<div className="mt-4 flex w-full justify-end">
<Button className="pointer-events-none" style={buttonStyle}>
Next
</Button>
</div>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-end">
<Button size="lg" className="mr-2" variant="minimal" id="product-skip" onClick={handleLaterClick}>
I&apos;ll do it later
</Button>
<Button
size="lg"
variant="darkCTA"
loading={isLoading}
disabled={!name || !environmentId}
onClick={handleDoneClick}>
{isLoading ? "Getting ready..." : "Done"}
</Button>
</div>
</div>
);
};
export default Product;

View File

@@ -0,0 +1,22 @@
import { Logo } from "@formbricks/ui/Logo";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
interface OnboardingHeaderProps {
progress: number;
}
export function OnboardingHeader({ progress }: OnboardingHeaderProps) {
return (
<div className="sticky z-50 mt-6 grid w-11/12 max-w-6xl grid-cols-6 items-center rounded-xl border border-slate-200 bg-white px-6 py-3">
<div className="col-span-2">
<Logo className="ml-4 w-1/2" />
</div>
<div className="col-span-1" />
<div className="col-span-3 flex items-center justify-center gap-8">
<div className="relative grow overflow-hidden rounded-full bg-slate-200">
<ProgressBar progress={progress / 100} barColor="bg-brand-dark" height={2} />
</div>
<span className="text-sm text-slate-800">{progress}% complete</span>
</div>
</div>
);
}

View File

@@ -1,146 +0,0 @@
"use client";
import { updateUserAction } from "@/app/(app)/onboarding/actions";
import { createResponse, formbricksEnabled } from "@/app/lib/formbricks";
import { Session } from "next-auth";
import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { env } from "@formbricks/lib/env";
import { Button } from "@formbricks/ui/Button";
import { handleTabNavigation } from "../utils";
type RoleProps = {
next: () => void;
skip: () => void;
setFormbricksResponseId: (id: string) => void;
session: Session;
};
type RoleChoice = {
label: string;
id: "project_manager" | "engineer" | "founder" | "marketing_specialist" | "other";
};
const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId, session }) => {
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const [isUpdating, setIsUpdating] = useState(false);
const fieldsetRef = useRef<HTMLFieldSetElement>(null);
useEffect(() => {
const onKeyDown = handleTabNavigation(fieldsetRef, setSelectedChoice);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [fieldsetRef, setSelectedChoice]);
const roles: Array<RoleChoice> = [
{ label: "Project Manager", id: "project_manager" },
{ label: "Engineer", id: "engineer" },
{ label: "Founder", id: "founder" },
{ label: "Marketing Specialist", id: "marketing_specialist" },
{ label: "Other", id: "other" },
];
const handleNextClick = async () => {
if (selectedChoice) {
const selectedRole = roles.find((role) => role.label === selectedChoice);
if (selectedRole) {
try {
setIsUpdating(true);
await updateUserAction({ role: selectedRole.id });
setIsUpdating(false);
} catch (e) {
setIsUpdating(false);
toast.error("An error occured saving your settings");
console.error(e);
}
if (formbricksEnabled && env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID) {
const res = await createResponse(env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID, session.user.id, {
role: selectedRole.label,
});
if (res.ok) {
const response = res.data;
setFormbricksResponseId(response.id);
} else {
console.error("Error sending response to Formbricks", res.error);
}
}
next();
}
}
};
return (
<div className="flex w-full max-w-xl flex-col gap-8 px-8">
<div className="px-4">
<label htmlFor="choices" 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>
<div className="mt-4">
<fieldset id="choices" aria-label="What is your role?" ref={fieldsetRef}>
<legend className="sr-only">Choices</legend>
<div className=" relative space-y-2 rounded-md">
{roles.map((choice) => (
<label
key={choice.id}
htmlFor={choice.id}
className={cn(
selectedChoice === choice.label
? "z-10 border-slate-400 bg-slate-100"
: "border-slate-200",
"relative flex cursor-pointer flex-col rounded-md border p-4 hover:bg-slate-100 focus:outline-none"
)}>
<span className="flex items-center text-sm">
<input
type="radio"
id={choice.id}
value={choice.label}
name="role"
checked={choice.label === selectedChoice}
className="checked:text-brand-dark focus:text-brand-dark h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={(e) => {
setSelectedChoice(e.currentTarget.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleNextClick();
}
}}
/>
<span id={`${choice.id}-label`} className="ml-3 font-medium">
{choice.label}
</span>
</span>
</label>
))}
</div>
</fieldset>
</div>
</div>
<div className="mb-24 flex justify-between">
<Button size="lg" className="text-slate-500" variant="minimal" onClick={skip} id="role-skip">
Skip
</Button>
<Button
size="lg"
variant="darkCTA"
loading={isUpdating}
disabled={!selectedChoice}
onClick={handleNextClick}
id="role-next">
Next
</Button>
</div>
</div>
);
};
export default Role;

View File

@@ -0,0 +1,150 @@
"use client";
import OnboardingTitle from "@/app/(app)/onboarding/components/OnboardingTitle";
import Dance from "@/images/onboarding-dance.gif";
import Lost from "@/images/onboarding-lost.gif";
import { ArrowRight } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { Button } from "@formbricks/ui/Button";
import { fetchEnvironment, finishOnboardingAction } from "../../actions";
import SetupInstructionsOnboarding from "./SetupInstructions";
const goToProduct = async (router) => {
if (typeof localStorage !== undefined) {
localStorage.removeItem("onboardingPathway");
localStorage.removeItem("onboardingCurrentStep");
}
await finishOnboardingAction();
router.push("/");
};
const goToTeamInvitePage = async () => {
localStorage.setItem("onboardingCurrentStep", "5");
};
// Custom hook for visibility change logic
const useVisibilityChange = (environment, setLocalEnvironment) => {
useEffect(() => {
const handleVisibilityChange = async () => {
if (document.visibilityState === "visible") {
const refetchedEnvironment = await fetchEnvironment(environment.id);
if (!refetchedEnvironment) return;
setLocalEnvironment(refetchedEnvironment);
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [environment, setLocalEnvironment]);
};
const ConnectedState = ({ goToProduct }) => {
const [isLoading, setIsLoading] = useState(false);
return (
<div className="flex w-full max-w-xl flex-col gap-8">
<OnboardingTitle title="We are connected!" subtitle="From now on it's a piece of cake 🍰" />
<div className="w-full space-y-8 rounded-lg border border-emerald-300 bg-emerald-50 p-8 text-center">
<Image src={Dance} alt="Dance" className="rounded-lg" />
<p className="text-lg font-semibold text-emerald-900">Connection successful </p>
</div>
<div className="mt-4 text-right">
<Button
variant="minimal"
loading={isLoading}
onClick={() => {
setIsLoading(true);
goToProduct();
}}>
Next <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
);
};
const NotConnectedState = ({ environment, webAppUrl, jsPackageVersion, goToTeamInvitePage }) => {
return (
<div className="group mb-8 w-full max-w-xl space-y-8">
<OnboardingTitle
title="Connect your app or website"
subtitle="It takes just a few minutes to set it up."
/>
<div className="flex w-full items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-12 py-3 text-slate-700">
Waiting for your signal...
<Image src={Lost} alt="lost" height={75} />
</div>
<div className="w-full border-b border-slate-200 " />
<SetupInstructionsOnboarding
environmentId={environment.id}
webAppUrl={webAppUrl}
jsPackageVersion={jsPackageVersion}
/>
<div className="flex justify-center">
<Button
className="opacity-0 transition-all delay-[3000ms] duration-500 ease-in-out group-hover:opacity-100"
variant="minimal"
onClick={goToTeamInvitePage}>
I am not sure how to do this
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
);
};
interface ConnectProps {
environment: TEnvironment;
webAppUrl: string;
jsPackageVersion: string;
setCurrentStep: (currentStep: number) => void;
}
export function ConnectWithFormbricks({
environment,
webAppUrl,
jsPackageVersion,
setCurrentStep,
}: ConnectProps) {
const router = useRouter();
const [localEnvironment, setLocalEnvironment] = useState(environment);
useVisibilityChange(environment, setLocalEnvironment);
useEffect(() => {
const fetchLatestEnvironmentOnFirstLoad = async () => {
const refetchedEnvironment = await fetchEnvironment(environment.id);
if (!refetchedEnvironment) return;
setLocalEnvironment(refetchedEnvironment);
};
fetchLatestEnvironmentOnFirstLoad();
}, []);
return localEnvironment.widgetSetupCompleted ? (
<ConnectedState
goToProduct={() => {
goToProduct(router);
}}
/>
) : (
<NotConnectedState
jsPackageVersion={jsPackageVersion}
webAppUrl={webAppUrl}
environment={environment}
goToTeamInvitePage={() => {
setCurrentStep(5);
localStorage.setItem("onboardingCurrentStep", "5");
goToTeamInvitePage();
}}
/>
);
}

View File

@@ -0,0 +1,128 @@
"use client";
import OnboardingTitle from "@/app/(app)/onboarding/components/OnboardingTitle";
import { ArrowRight } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { TTeam } from "@formbricks/types/teams";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { finishOnboardingAction, inviteTeamMateAction } from "../../actions";
interface InviteTeamMateProps {
team: TTeam;
environmentId: string;
setCurrentStep: (currentStep: number) => void;
}
const DEFAULT_INVITE_MESSAGE =
"I'm looking into Formbricks to run targeted surveys. Can you help me set it up? 🙏";
const INITIAL_FORM_STATE = { email: "", inviteMessage: DEFAULT_INVITE_MESSAGE };
function isValidEmail(email) {
const regex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;
return regex.test(email);
}
function InviteMessageInput({ value, onChange }) {
return (
<textarea
rows={5}
placeholder="engineering@acme.com"
className="focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-transparent bg-white px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300"
value={value}
onChange={onChange}
/>
);
}
export function InviteTeamMate({ team, environmentId, setCurrentStep }: InviteTeamMateProps) {
const [formState, setFormState] = useState(INITIAL_FORM_STATE);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleInputChange = (e, name) => {
const value = e.target.value;
setFormState({ ...formState, [name]: value });
};
const handleInvite = async () => {
if (!isValidEmail(formState.email)) {
toast.error("Invalid Email");
return;
}
try {
await inviteTeamMateAction(team.id, formState.email, "developer", formState.inviteMessage);
toast.success("Invite sent successful");
goToProduct();
} catch (error) {
toast.error(error.message || "An unexpected error occurred");
}
};
const goToProduct = async () => {
setIsLoading(true);
try {
if (typeof localStorage !== undefined) {
localStorage.removeItem("onboardingPathway");
localStorage.removeItem("onboardingCurrentStep");
}
await finishOnboardingAction();
router.push(`/environments/${environmentId}/surveys`);
} catch (error) {
toast.error("An error occurred saving your settings.");
console.error(error);
}
};
const goBackToConnectPage = () => {
setCurrentStep(4);
localStorage.setItem("onboardingCurrentStep", "4");
};
return (
<div className="group mb-8 w-full max-w-xl space-y-8">
<OnboardingTitle
title="Invite your team to help out"
subtitle="Ask your tech-savvy co-worker to finish the setup:"
/>
<div className="flex h-[65vh] flex-col justify-between">
<div className="space-y-4">
<Input
tabIndex={0}
placeholder="engineering@acme.com"
className="w-full bg-white"
value={formState.email}
onChange={(e) => handleInputChange(e, "email")}
/>
<InviteMessageInput
value={formState.inviteMessage}
onChange={(e) => handleInputChange(e, "inviteMessage")}
/>
<div className="flex w-full justify-between">
<Button variant="minimal" onClick={() => goBackToConnectPage()}>
Back
</Button>
<Button variant="darkCTA" onClick={handleInvite}>
Invite
</Button>
</div>
</div>
<div className="mt-auto flex justify-center">
<Button
className="opacity-0 transition-all delay-[3000ms] duration-500 ease-in-out group-hover:opacity-100"
variant="minimal"
onClick={goToProduct}
loading={isLoading}>
I want to have a look around first <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
"use client";
import "prismjs/themes/prism.css";
import { useState } from "react";
import toast from "react-hot-toast";
import { IoLogoHtml5, IoLogoNpm } from "react-icons/io5";
import { cn } from "@formbricks/lib/cn";
import { Button } from "@formbricks/ui/Button";
import CodeBlock from "@formbricks/ui/CodeBlock";
const tabs = [
{ id: "html", label: "HTML", icon: <IoLogoHtml5 /> },
{ id: "npm", label: "NPM", icon: <IoLogoNpm /> },
];
interface SetupInstructionsOnboardingProps {
environmentId: string;
webAppUrl: string;
jsPackageVersion: string;
}
export default function SetupInstructionsOnboarding({
environmentId,
webAppUrl,
jsPackageVersion,
}: SetupInstructionsOnboardingProps) {
const [activeTab, setActiveId] = useState(tabs[0].id);
const htmlSnippet = `<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="https://unpkg.com/@formbricks/js@^${jsPackageVersion}/dist/index.umd.js";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: "${environmentId}", apiHost: "${window.location.protocol}//${window.location.host}"})},500)}();
</script>
<!-- END Formbricks Surveys -->`;
return (
<div>
<div className="flex h-14 w-full items-center justify-center rounded-md border border-slate-200 bg-white">
<nav className="flex h-full w-full items-center space-x-4 p-1.5" aria-label="Tabs">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveId(tab.id)}
className={cn(
tab.id === activeTab
? " bg-slate-100 font-semibold text-slate-900"
: "text-slate-500 transition-all duration-300 hover:bg-slate-50 hover:text-slate-700",
"flex h-full w-full items-center justify-center rounded-md px-3 py-2 text-center text-sm font-medium"
)}
aria-current={tab.id === activeTab ? "page" : undefined}>
{tab.icon && <div className="flex h-5 w-5 items-center">{tab.icon}</div>}
{tab.label}
</button>
))}
</nav>
</div>
<div className="">
{activeTab === "npm" ? (
<div className="prose prose-slate">
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="sh">
npm install @formbricks/js --save
</CodeBlock>
<p className="text-sm text-slate-700">
Import Formbricks and initialize the widget in your Component (e.g. App.tsx):
</p>
<CodeBlock
customEditorClass="!bg-white border border-slate-200"
language="js">{`import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.init({
environmentId: "${environmentId}",
apiHost: "${webAppUrl}",
});
}`}</CodeBlock>
<Button
className="mt-3"
variant="secondary"
href="https://formbricks.com/docs/getting-started/framework-guides"
target="_blank">
Read docs
</Button>
</div>
) : activeTab === "html" ? (
<div className="prose prose-slate">
<p className="-mb-1 mt-6 text-sm text-slate-700">
Insert this code into the &lt;head&gt; tag of your website:
</p>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
{htmlSnippet}
</CodeBlock>
<div className="mt-4 space-x-2">
<Button
variant="darkCTA"
onClick={() => {
navigator.clipboard.writeText(htmlSnippet);
toast.success("Copied to clipboard");
}}>
Copy code
</Button>
<Button
variant="secondary"
href="https://formbricks.com/docs/getting-started/framework-guides#html"
target="_blank">
Step by step manual
</Button>
</div>
</div>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,164 @@
"use client";
import { updateUserAction } from "@/app/(app)/onboarding/actions";
import OnboardingTitle from "@/app/(app)/onboarding/components/OnboardingTitle";
import { handleTabNavigation } from "@/app/(app)/onboarding/utils";
import { formbricksEnabled, updateResponse } from "@/app/lib/formbricks";
import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { env } from "@formbricks/lib/env";
import { TUser, TUserObjective } from "@formbricks/types/user";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
type ObjectiveProps = {
formbricksResponseId?: string;
user: TUser;
setCurrentStep: (currentStep: number) => void;
};
type ObjectiveChoice = {
label: string;
id: TUserObjective;
};
export const Objective: React.FC<ObjectiveProps> = ({ formbricksResponseId, user, setCurrentStep }) => {
const objectives: Array<ObjectiveChoice> = [
{ label: "Increase conversion", id: "increase_conversion" },
{ label: "Improve user retention", id: "improve_user_retention" },
{ label: "Increase user adoption", id: "increase_user_adoption" },
{ label: "Sharpen marketing messaging", id: "sharpen_marketing_messaging" },
{ label: "Support sales", id: "support_sales" },
{ label: "Other", id: "other" },
];
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const [isProfileUpdating, setIsProfileUpdating] = useState(false);
const [otherValue, setOtherValue] = useState("");
const fieldsetRef = useRef<HTMLFieldSetElement>(null);
useEffect(() => {
const onKeyDown = handleTabNavigation(fieldsetRef, setSelectedChoice);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [fieldsetRef, setSelectedChoice]);
const next = () => {
setCurrentStep(4);
localStorage.setItem("onboardingCurrentStep", "4");
};
const handleNextClick = async () => {
if (selectedChoice === "Other" && otherValue.trim() === "") {
toast.error("Other value missing");
return;
}
if (selectedChoice) {
const selectedObjective = objectives.find((objective) => objective.label === selectedChoice);
if (selectedObjective) {
try {
setIsProfileUpdating(true);
await updateUserAction({
objective: selectedObjective.id,
name: user.name ?? undefined,
});
setIsProfileUpdating(false);
} catch (e) {
setIsProfileUpdating(false);
console.error(e);
toast.error("An error occured saving your settings");
}
if (formbricksEnabled && env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID && formbricksResponseId) {
const res = await updateResponse(
formbricksResponseId,
{
objective: selectedObjective.id === "other" ? otherValue : selectedObjective.label,
},
true
);
if (!res.ok) {
console.error("Error updating response", res.error);
}
}
next();
}
}
};
return (
<div className="flex w-full max-w-xl flex-col gap-8">
<OnboardingTitle
title="What do you want to achieve?"
subtitle="We suggest templates based on your selection."
/>
<fieldset id="choices" aria-label="What do you want to achieve?" ref={fieldsetRef}>
<legend className="sr-only">Choices</legend>
<div className=" relative space-y-2 rounded-md">
{objectives.map((choice) => (
<label
key={choice.id}
className={cn(
selectedChoice === choice.label
? "z-10 border-slate-400 bg-slate-100"
: "border-slate-200 bg-white hover:bg-slate-50",
"relative flex cursor-pointer flex-col rounded-md border p-4 focus:outline-none"
)}>
<span className="flex items-center">
<input
type="radio"
id={choice.id}
value={choice.label}
checked={choice.label === selectedChoice}
className="checked:text-brand-dark focus:text-brand-dark h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={(e) => {
setSelectedChoice(e.currentTarget.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleNextClick();
}
}}
/>
<span id={`${choice.id}-label`} className="ml-3 text-sm text-slate-700">
{choice.label}
</span>
</span>
{choice.id === "other" && selectedChoice === "Other" && (
<div className="mt-4 w-full">
<Input
className="bg-white"
autoFocus
required
placeholder="Please specify"
value={otherValue}
onChange={(e) => setOtherValue(e.target.value)}
/>
</div>
)}
</label>
))}
</div>
</fieldset>
<div className="flex justify-between">
<Button className="text-slate-500" variant="minimal" onClick={next} id="objective-skip">
Skip
</Button>
<Button
variant="darkCTA"
loading={isProfileUpdating}
disabled={!selectedChoice}
onClick={handleNextClick}
id="objective-next">
Next
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,160 @@
"use client";
import { updateUserAction } from "@/app/(app)/onboarding/actions";
import OnboardingTitle from "@/app/(app)/onboarding/components/OnboardingTitle";
import { handleTabNavigation } from "@/app/(app)/onboarding/utils";
import { createResponse, formbricksEnabled } from "@/app/lib/formbricks";
import { Session } from "next-auth";
import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { env } from "@formbricks/lib/env";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
type RoleProps = {
setFormbricksResponseId: (id: string) => void;
session: Session;
setCurrentStep: (currentStep: number) => void;
};
type RoleChoice = {
label: string;
id: "project_manager" | "engineer" | "founder" | "marketing_specialist" | "other";
};
export const Role: React.FC<RoleProps> = ({ setFormbricksResponseId, session, setCurrentStep }) => {
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const [isUpdating, setIsUpdating] = useState(false);
const fieldsetRef = useRef<HTMLFieldSetElement>(null);
const [otherValue, setOtherValue] = useState("");
useEffect(() => {
const onKeyDown = handleTabNavigation(fieldsetRef, setSelectedChoice);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [fieldsetRef, setSelectedChoice]);
const roles: Array<RoleChoice> = [
{ label: "Project Manager", id: "project_manager" },
{ label: "Engineer", id: "engineer" },
{ label: "Founder", id: "founder" },
{ label: "Marketing Specialist", id: "marketing_specialist" },
{ label: "Other", id: "other" },
];
const next = () => {
setCurrentStep(3);
localStorage.setItem("onboardingCurrentStep", "3");
};
const handleNextClick = async () => {
if (selectedChoice === "Other" && otherValue.trim() === "") {
toast.error("Other value missing");
return;
}
if (selectedChoice) {
const selectedRole = roles.find((role) => role.label === selectedChoice);
if (selectedRole) {
try {
setIsUpdating(true);
await updateUserAction({
role: selectedRole.id,
});
setIsUpdating(false);
} catch (e) {
setIsUpdating(false);
toast.error("An error occured saving your settings");
console.error(e);
}
if (formbricksEnabled && env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID) {
const res = await createResponse(env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID, session.user.id, {
role: selectedRole.id === "other" ? otherValue : selectedRole.label,
});
if (res.ok) {
const response = res.data;
setFormbricksResponseId(response.id);
} else {
console.error("Error sending response to Formbricks", res.error);
}
}
next();
}
}
};
return (
<div className="flex w-full max-w-xl flex-col gap-8">
<OnboardingTitle
title="What is your role?"
subtitle="Make your Formbricks experience more personalised."
/>
<fieldset id="choices" aria-label="What is your role?" ref={fieldsetRef}>
<legend className="sr-only">Choices</legend>
<div className="relative space-y-2 rounded-md">
{roles.map((choice) => (
<label
key={choice.id}
htmlFor={choice.id}
className={cn(
selectedChoice === choice.label
? "z-10 border-slate-400 bg-slate-100"
: "border-slate-200 bg-white hover:bg-slate-50",
"relative flex cursor-pointer flex-col rounded-md border p-4 focus:outline-none"
)}>
<span className="flex items-center">
<input
type="radio"
id={choice.id}
value={choice.label}
name="role"
checked={choice.label === selectedChoice}
className="checked:text-brand-dark focus:text-brand-dark h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={(e) => {
setSelectedChoice(e.currentTarget.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleNextClick();
}
}}
/>
<span id={`${choice.id}-label`} className="ml-3 text-sm text-slate-700">
{choice.label}
</span>
</span>
{choice.id === "other" && selectedChoice === "Other" && (
<div className="mt-4 w-full">
<Input
className="bg-white"
autoFocus
placeholder="Please specify"
value={otherValue}
onChange={(e) => setOtherValue(e.target.value)}
/>
</div>
)}
</label>
))}
</div>
</fieldset>
<div className="flex justify-between">
<Button className="text-slate-500" variant="minimal" onClick={next} id="role-skip">
Skip
</Button>
<Button
variant="darkCTA"
loading={isUpdating}
disabled={!selectedChoice}
onClick={handleNextClick}
id="role-next">
Next
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,86 @@
"use client";
import {
customSurvey,
templates,
} from "@/app/(app)/environments/[environmentId]/surveys/templates/templates";
import OnboardingTitle from "@/app/(app)/onboarding/components/OnboardingTitle";
import ChurnImage from "@/images/onboarding-churn.png";
import FeedbackImage from "@/images/onboarding-collect-feedback.png";
import NPSImage from "@/images/onboarding-nps.png";
import { ArrowRight } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { TTemplate } from "@formbricks/types/templates";
import { Button } from "@formbricks/ui/Button";
import { OptionCard } from "@formbricks/ui/OptionCard";
import { createSurveyFromTemplate, finishOnboardingAction } from "../../actions";
interface CreateFirstSurveyProps {
environmentId: string;
}
export function CreateFirstSurvey({ environmentId }: CreateFirstSurveyProps) {
const router = useRouter();
const [loadingTemplate, setLoadingTemplate] = useState<string | null>(null);
const templateOrder = ["Collect Feedback", "Net Promoter Score (NPS)", "Churn Survey"];
const templateImages = {
"Collect Feedback": FeedbackImage,
"Net Promoter Score (NPS)": NPSImage,
"Churn Survey": ChurnImage,
};
const filteredTemplates = templates
.filter((template) => templateOrder.includes(template.name))
.sort((a, b) => templateOrder.indexOf(a.name) - templateOrder.indexOf(b.name));
const newSurveyFromTemplate = async (template: TTemplate) => {
setLoadingTemplate(template.name);
if (typeof localStorage !== undefined) {
localStorage.removeItem("onboardingPathway");
localStorage.removeItem("onboardingCurrentStep");
}
await finishOnboardingAction();
try {
const survey = await createSurveyFromTemplate(template, environmentId);
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
} catch (e) {
toast.error("An error occurred creating a new survey");
}
};
return (
<div className="flex flex-col items-center space-y-16">
<OnboardingTitle title="Create your first survey" subtitle="Pick a template or start from scratch." />
<div className="grid w-11/12 max-w-6xl grid-cols-3 grid-rows-1 gap-6">
{filteredTemplates.map((template) => {
const TemplateImage = templateImages[template.name];
return (
<OptionCard
size="md"
key={template.name}
title={template.name}
description={template.description}
onSelect={() => newSurveyFromTemplate(template)}
loading={loadingTemplate === template.name}>
<Image src={TemplateImage} alt={template.name} className="rounded-md border border-slate-300" />
</OptionCard>
);
})}
</div>
<Button
size="lg"
variant="secondary"
loading={loadingTemplate === "Start from scratch"}
onClick={() => {
newSurveyFromTemplate(customSurvey);
}}>
Start from scratch <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,166 @@
"use client";
import jsPackageJson from "@/../../packages/js/package.json";
import { ConnectWithFormbricks } from "@/app/(app)/onboarding/components/inapp/ConnectWithFormbricks";
import { InviteTeamMate } from "@/app/(app)/onboarding/components/inapp/InviteTeamMate";
import { Objective } from "@/app/(app)/onboarding/components/inapp/SurveyObjective";
import { Role } from "@/app/(app)/onboarding/components/inapp/SurveyRole";
import { CreateFirstSurvey } from "@/app/(app)/onboarding/components/link/CreateFirstSurvey";
import { Session } from "next-auth";
import { useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TTeam } from "@formbricks/types/teams";
import { TUser } from "@formbricks/types/user";
import PathwaySelect from "./PathwaySelect";
import { OnboardingHeader } from "./ProgressBar";
interface OnboardingProps {
isFormbricksCloud: boolean;
session: Session;
environment: TEnvironment;
user: TUser;
team: TTeam;
webAppUrl: string;
}
export function Onboarding({
isFormbricksCloud,
session,
environment,
user,
team,
webAppUrl,
}: OnboardingProps) {
const [selectedPathway, setSelectedPathway] = useState<string | null>(null);
const [progress, setProgress] = useState<number>(16);
const [formbricksResponseId, setFormbricksResponseId] = useState<string | undefined>();
const [currentStep, setCurrentStep] = useState<number | null>(null);
const [iframeLoaded, setIframeLoaded] = useState(false);
const [iframeVisible, setIframeVisible] = useState(false);
const [fade, setFade] = useState(false);
useEffect(() => {
if (currentStep === 2 && selectedPathway === "link") {
setIframeVisible(true);
} else {
setIframeVisible(false);
}
}, [currentStep, iframeLoaded, selectedPathway]);
useEffect(() => {
if (iframeVisible) {
setFade(true);
const handleSurveyCompletion = () => {
setFade(false);
setTimeout(() => {
setIframeVisible(false); // Hide the iframe after fade-out effect is complete
setCurrentStep(5); // Assuming you want to move to the next step after survey completion
}, 1000); // Adjust timeout duration based on your fade-out CSS transition
};
window.addEventListener("formbricksSurveyCompleted", handleSurveyCompletion);
// Cleanup function to remove the event listener
return () => {
window.removeEventListener("formbricksSurveyCompleted", handleSurveyCompletion);
};
}
}, [iframeVisible, currentStep]); // Depend on iframeVisible and currentStep to re-evaluate when needed
useEffect(() => {
if (typeof window !== "undefined") {
// Access localStorage only when window is available
const pathwayValueFromLocalStorage = localStorage.getItem("onboardingPathway");
const currentStepValueFromLocalStorage = parseInt(localStorage.getItem("onboardingCurrentStep") ?? "1");
setSelectedPathway(pathwayValueFromLocalStorage);
setCurrentStep(currentStepValueFromLocalStorage);
}
}, []);
useEffect(() => {
if (currentStep) {
const stepProgressMap = { 1: 16, 2: 50, 3: 65, 4: 75, 5: 90 };
const newProgress = stepProgressMap[currentStep] || 16;
setProgress(newProgress);
localStorage.setItem("onboardingCurrentStep", currentStep.toString());
}
}, [currentStep]);
// Function to render current onboarding step
const renderOnboardingStep = () => {
switch (currentStep) {
case 1:
return (
<PathwaySelect
setSelectedPathway={setSelectedPathway}
setCurrentStep={setCurrentStep}
isFormbricksCloud={isFormbricksCloud}
/>
);
case 2:
return (
selectedPathway !== "link" && (
<Role
setFormbricksResponseId={setFormbricksResponseId}
session={session}
setCurrentStep={setCurrentStep}
/>
)
);
case 3:
return (
<Objective
formbricksResponseId={formbricksResponseId}
user={user}
setCurrentStep={setCurrentStep}
/>
);
case 4:
return (
<ConnectWithFormbricks
environment={environment}
webAppUrl={webAppUrl}
jsPackageVersion={jsPackageJson.version}
setCurrentStep={setCurrentStep}
/>
);
case 5:
return selectedPathway === "link" ? (
<CreateFirstSurvey environmentId={environment.id} />
) : (
<InviteTeamMate environmentId={environment.id} team={team} setCurrentStep={setCurrentStep} />
);
default:
return null;
}
};
return (
<div className="flex h-full w-full flex-col items-center bg-slate-50">
<OnboardingHeader progress={progress} />
<div className="mt-20 flex w-full justify-center bg-slate-50">
{renderOnboardingStep()}
{iframeVisible && isFormbricksCloud && (
<iframe
src={`https://app.formbricks.com/s/clr737oiseav88up09skt2hxo?userId=${session.user.id}`}
onLoad={() => setIframeLoaded(true)}
style={{
inset: "0",
position: "absolute",
width: "100%",
height: "100%",
border: "0",
zIndex: "40",
transition: "opacity 1s ease",
opacity: fade ? "1" : "0", // 1 for fade in, 0 for fade out
}}></iframe>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@formbricks/lib/authOptions";
import ToasterClient from "@formbricks/ui/ToasterClient";
export default async function EnvironmentLayout({ children }) {
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return redirect(`/auth/login`);
}
return (
<div className="h-full w-full bg-slate-50">
<ToasterClient />
{children}
</div>
);
}

View File

@@ -1,13 +0,0 @@
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-slate-200"></div>
<div className="h-12 w-1/3 rounded-lg bg-slate-200"></div>
<div className="h-0 w-1/6"></div>
</div>
<div className="h-1/3 w-1/2 rounded-lg bg-slate-200"></div>
<div className="h-10 w-1/2 rounded-lg bg-slate-200"></div>
</div>
);
}

View File

@@ -1,31 +1,44 @@
import { Onboarding } from "@/app/(app)/onboarding/components/onboarding";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@formbricks/lib/authOptions";
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@formbricks/lib/constants";
import { getFirstEnvironmentByUserId } from "@formbricks/lib/environment/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { getUser } from "@formbricks/lib/user/service";
import Onboarding from "./components/Onboarding";
export default async function OnboardingPage() {
const session = await getServerSession(authOptions);
// Redirect to login if not authenticated
if (!session) {
redirect("/auth/login");
return redirect("/auth/login");
}
const userId = session?.user.id;
// Redirect to home if onboarding is completed
if (session.user.onboardingCompleted) {
return redirect("/");
}
const userId = session.user.id;
const environment = await getFirstEnvironmentByUserId(userId);
if (!environment) {
throw new Error("No environment found for user");
}
const user = await getUser(userId);
const product = await getProductByEnvironmentId(environment?.id!);
const team = environment ? await getTeamByEnvironmentId(environment.id) : null;
if (!environment || !user || !product) {
throw new Error("Failed to get environment, user, or product");
// Ensure all necessary data is available
if (!environment || !user || !team) {
throw new Error("Failed to get necessary user, environment, or team information");
}
return <Onboarding session={session} environmentId={environment.id} user={user} product={product} />;
return (
<Onboarding
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
session={session}
environment={environment}
user={user}
team={team}
webAppUrl={WEBAPP_URL}
/>
);
}

View File

@@ -1,4 +1,3 @@
// util.js
export const handleTabNavigation = (fieldsetRef, setSelectedChoice) => (event) => {
if (event.key !== "Tab") {
return;

View File

@@ -1,13 +1,18 @@
import { getFirstSurvey } from "@/app/(app)/environments/[environmentId]/surveys/templates/templates";
import { sendFreeLimitReachedEventToPosthogBiWeekly } from "@/app/api/v1/client/[environmentId]/in-app/sync/lib/posthog";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { NextRequest } from "next/server";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { IS_FORMBRICKS_CLOUD, PRICING_APPSURVEYS_FREE_RESPONSES } from "@formbricks/lib/constants";
import {
IS_FORMBRICKS_CLOUD,
PRICING_APPSURVEYS_FREE_RESPONSES,
WEBAPP_URL,
} from "@formbricks/lib/constants";
import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { createSurvey, getSurveys } from "@formbricks/lib/survey/service";
import { getMonthlyTeamResponseCount, getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { TJsStateSync, ZJsPublicSyncInput } from "@formbricks/types/js";
@@ -63,6 +68,8 @@ export async function GET(
}
if (!environment?.widgetSetupCompleted) {
const firstSurvey = getFirstSurvey(WEBAPP_URL);
await createSurvey(environmentId, firstSurvey);
await updateEnvironment(environment.id, { widgetSetupCompleted: true });
}

View File

@@ -36,6 +36,7 @@ export const questionTypes: TSurveyQuestionType[] = [
subheader: "Who? Who? Who?",
placeholder: "Type your answer here...",
longAnswer: true,
inputType: "text",
},
},
{

View File

@@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useRef } from "react";
import React, { useEffect, useRef, useState } from "react";
import { TSurvey } from "@formbricks/types/surveys";
@@ -20,15 +20,30 @@ export const MediaBackground: React.FC<MediaBackgroundProps> = ({
ContentRef,
}) => {
const animatedBackgroundRef = useRef<HTMLVideoElement>(null);
const [backgroundLoaded, setBackgroundLoaded] = useState(false);
useEffect(() => {
if (survey.styling?.background?.bgType === "animation") {
if (animatedBackgroundRef.current && survey.styling?.background?.bg) {
animatedBackgroundRef.current.src = survey.styling?.background?.bg;
animatedBackgroundRef.current.play();
}
if (survey.styling?.background?.bgType === "animation" && animatedBackgroundRef.current) {
const video = animatedBackgroundRef.current;
const onCanPlayThrough = () => setBackgroundLoaded(true);
video.addEventListener("canplaythrough", onCanPlayThrough);
video.src = survey.styling?.background?.bg || "";
// Cleanup
return () => video.removeEventListener("canplaythrough", onCanPlayThrough);
} else if (survey.styling?.background?.bgType === "image" && survey.styling?.background?.bg) {
// For images, we create a new Image object to listen for the 'load' event
const img = new Image();
img.onload = () => setBackgroundLoaded(true);
img.src = survey.styling?.background?.bg;
} else {
// For colors or any other types, set to loaded immediately
setBackgroundLoaded(true);
}
}, [survey.styling?.background?.bg, survey.styling?.background?.bgType]);
}, [survey.styling?.background]);
const baseClasses = "absolute inset-0 h-full w-full transition-opacity duration-500";
const loadedClass = backgroundLoaded ? "opacity-100" : "opacity-0";
const getFilterStyle = () => {
return survey.styling?.background?.brightness
@@ -38,13 +53,12 @@ export const MediaBackground: React.FC<MediaBackgroundProps> = ({
const renderBackground = () => {
const filterStyle = getFilterStyle();
const baseClasses = "absolute inset-0 h-full w-full";
switch (survey.styling?.background?.bgType) {
case "color":
return (
<div
className={`${baseClasses}`}
className={`${baseClasses} ${loadedClass}`}
style={{ backgroundColor: survey.styling?.background?.bg || "#ffff", filter: `${filterStyle}` }}
/>
);
@@ -55,23 +69,20 @@ export const MediaBackground: React.FC<MediaBackgroundProps> = ({
muted
loop
autoPlay
className={`${baseClasses} object-cover`}
className={`${baseClasses} ${loadedClass} object-cover`}
style={{ filter: `${filterStyle}` }}>
<source
src={survey.styling?.background?.bg || "/animated-bgs/Thumbnails/1_Thumb.mp4"}
type="video/mp4"
/>
<source src={survey.styling?.background?.bg || ""} type="video/mp4" />
</video>
);
case "image":
return (
<div
className={`${baseClasses} bg-cover bg-center`}
className={`${baseClasses} ${loadedClass} bg-cover bg-center`}
style={{ backgroundImage: `url(${survey.styling?.background?.bg})`, filter: `${filterStyle}` }}
/>
);
default:
return <div className={`${baseClasses} bg-white`} />;
return <div className={`${baseClasses} ${loadedClass} bg-white`} />;
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View File

@@ -1,7 +1,7 @@
import { actions, users } from "@/playwright/utils/mock";
import { Page, expect, test } from "@playwright/test";
import { login, signUpAndLogin, skipOnboarding } from "./utils/helper";
import { finishOnboarding, login, signUpAndLogin } from "./utils/helper";
const createNoCodeActionByCSSSelector = async (
page: Page,
@@ -13,7 +13,7 @@ const createNoCodeActionByCSSSelector = async (
selector: string
) => {
await signUpAndLogin(page, username, email, password);
await skipOnboarding(page);
await finishOnboarding(page);
await page.getByRole("link", { name: "Actions & Attributes" }).click();
await page.waitForURL(/\/environments\/[^/]+\/actions/);
@@ -55,7 +55,7 @@ const createNoCodeActionByPageURL = async (
testURL: string
) => {
await signUpAndLogin(page, username, email, password);
await skipOnboarding(page);
await finishOnboarding(page);
await page.getByRole("link", { name: "Actions & Attributes" }).click();
await page.waitForURL(/\/environments\/[^/]+\/actions/);
@@ -104,7 +104,7 @@ const createNoCodeActionByInnerText = async (
innerText: string
) => {
await signUpAndLogin(page, username, email, password);
await skipOnboarding(page);
await finishOnboarding(page);
await page.getByRole("link", { name: "Actions & Attributes" }).click();
await page.waitForURL(/\/environments\/[^/]+\/actions/);
@@ -276,7 +276,7 @@ test.describe("Create and Edit Code Action", async () => {
test("Create Code Action", async ({ page }) => {
await signUpAndLogin(page, username, email, password);
await skipOnboarding(page);
await finishOnboarding(page);
await page.getByRole("link", { name: "Actions & Attributes" }).click();
await page.waitForURL(/\/environments\/[^/]+\/actions/);

View File

@@ -1,6 +1,6 @@
import { expect, test } from "@playwright/test";
import { login, replaceEnvironmentIdInHtml, signUpAndLogin, skipOnboarding } from "./utils/helper";
import { finishOnboarding, login, replaceEnvironmentIdInHtml, signUpAndLogin } from "./utils/helper";
import { users } from "./utils/mock";
test.describe("JS Package Test", async () => {
@@ -10,7 +10,7 @@ test.describe("JS Package Test", async () => {
test("Admin creates an In-App Survey", async ({ page }) => {
await signUpAndLogin(page, name, email, password);
await skipOnboarding(page);
await finishOnboarding(page);
await page.waitForURL(/\/environments\/[^/]+\/surveys/);

View File

@@ -3,48 +3,32 @@ import { expect, test } from "@playwright/test";
import { signUpAndLogin } from "./utils/helper";
import { teams, users } from "./utils/mock";
const { role, productName, useCase } = teams.onboarding[0];
const { productName } = teams.onboarding[0];
test.describe("Onboarding Flow Test", async () => {
test("Step by Step", async ({ page }) => {
test("link survey", async ({ page }) => {
const { name, email, password } = users.onboarding[0];
await signUpAndLogin(page, name, email, password);
await page.waitForURL("/onboarding");
await expect(page).toHaveURL("/onboarding");
await page.getByRole("button", { name: "Begin (1 min)" }).click();
await page.getByLabel(role).check();
await page.getByRole("button", { name: "Next" }).click();
await expect(page.getByLabel(useCase)).toBeVisible();
await page.getByLabel(useCase).check();
await page.getByRole("button", { name: "Next" }).click();
await expect(page.getByPlaceholder("e.g. Formbricks")).toBeVisible();
await page.getByPlaceholder("e.g. Formbricks").fill(productName);
await page.locator("#color-picker").click();
await page.getByLabel("Hue").click();
await page.locator("div").filter({ hasText: "Create your team's product." }).nth(1).click();
await page.getByRole("button", { name: "Done" }).click();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
await expect(page).toHaveURL(/\/environments\/[^/]+\/surveys/);
await expect(page.getByText(productName)).toBeVisible();
await page.getByRole("button", { name: "Link Surveys Create a new" }).click();
await page.getByRole("button", { name: "Collect Feedback Collect" }).click();
await page.getByRole("button", { name: "Back", exact: true }).click();
await page.getByRole("button", { name: "Save" }).click();
});
test("Skip", async ({ page }) => {
test("In app survey", async ({ page }) => {
const { name, email, password } = users.onboarding[1];
await signUpAndLogin(page, name, email, password);
await page.waitForURL("/onboarding");
await expect(page).toHaveURL("/onboarding");
await page.getByRole("button", { name: "I'll do it later" }).click();
await page.getByRole("button", { name: "I'll do it later" }).click();
await page.getByRole("button", { name: "In-app Surveys Run a survey" }).click();
await page.getByRole("button", { name: "I am not sure how to do this" }).click();
await page.locator("input").click();
await page.locator("input").fill("test@gmail.com");
await page.getByRole("button", { name: "Invite" }).click();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
await expect(page).toHaveURL(/\/environments\/[^/]+\/surveys/);
await expect(page.getByText("My Product")).toBeVisible();
await expect(page.getByText(productName)).toBeVisible();
});
});

View File

@@ -1,6 +1,6 @@
import { expect, test } from "playwright/test";
import { login, signUpAndLogin, signupUsingInviteToken, skipOnboarding } from "./utils/helper";
import { finishOnboarding, login, signUpAndLogin, signupUsingInviteToken } from "./utils/helper";
import { invites, users } from "./utils/mock";
test.describe("Invite, accept and remove team member", async () => {
@@ -10,7 +10,7 @@ test.describe("Invite, accept and remove team member", async () => {
test("Invite team member", async ({ page }) => {
await signUpAndLogin(page, name, email, password);
await skipOnboarding(page);
await finishOnboarding(page);
const dropdownTrigger = page.locator("#userDropdownTrigger");
await expect(dropdownTrigger).toBeVisible();
@@ -84,7 +84,7 @@ test.describe("Invite, accept and remove team member", async () => {
await page.getByRole("link", { name: "Create account" }).click();
await signupUsingInviteToken(page, name, email, password);
await skipOnboarding(page);
await finishOnboarding(page);
});
test("Remove member", async ({ page }) => {

View File

@@ -37,14 +37,15 @@ export const login = async (page: Page, email: string, password: string): Promis
await page.getByRole("button", { name: "Login with Email" }).click();
};
export const skipOnboarding = async (page: Page): Promise<void> => {
export const finishOnboarding = async (page: Page): Promise<void> => {
await page.waitForURL("/onboarding");
await expect(page).toHaveURL("/onboarding");
await page.getByRole("button", { name: "I'll do it later" }).click();
await page.waitForTimeout(500);
await page.getByRole("button", { name: "I'll do it later" }).click();
await page.getByRole("button", { name: "In-app Surveys Run a survey" }).click();
await page.getByRole("button", { name: "I am not sure how to do this" }).click();
await page.locator("input").click();
await page.locator("input").fill("test@gmail.com");
await page.getByRole("button", { name: "Invite" }).click();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
await expect(page).toHaveURL(/\/environments\/[^/]+\/surveys/);
await expect(page.getByText("My Product")).toBeVisible();
};
@@ -87,7 +88,7 @@ export const createSurvey = async (
const addQuestion = "Add QuestionAdd a new question to your survey";
await signUpAndLogin(page, name, email, password);
await skipOnboarding(page);
await finishOnboarding(page);
await page.getByRole("heading", { name: "Start from Scratch" }).click();

View File

@@ -21,7 +21,7 @@ export const users = {
survey: [
{
name: "Survey User 1",
email: "survey1@formbricks.com",
email: "survey3@formbricks.com",
password: "Y1I*EpURUSb32j5XijP",
},
{
@@ -83,7 +83,7 @@ export const teams = {
{
role: "Founder",
useCase: "Increase conversion",
productName: "Formbricks E2E Test Suite",
productName: "My Product",
},
],
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

View File

@@ -7,7 +7,7 @@
"maxDuration": 10,
"memory": 300
},
"app/**/*.ts": {
"**/*.ts": {
"maxDuration": 10,
"memory": 512
}

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) => {

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;
};

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,
};

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,

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,
},
}

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);

View File

@@ -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);

View File

@@ -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>

View File

@@ -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",

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>;

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>

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;
}

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>
);

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>
);
};

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 (

View File

@@ -3,6 +3,6 @@
"include": [".", "../types/*.d.ts"],
"exclude": ["build", "node_modules"],
"compilerOptions": {
"lib": ["ES2021.String"]
}
"lib": ["ES2021.String"],
},
}