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>
@@ -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",
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ All you need to do is copy a `<script>` tag to your HTML head, and that’s 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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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'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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
18
apps/web/app/(app)/onboarding/components/OnboardingTitle.tsx
Normal 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;
|
||||
67
apps/web/app/(app)/onboarding/components/PathwaySelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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;
|
||||
22
apps/web/app/(app)/onboarding/components/ProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 <head> 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
160
apps/web/app/(app)/onboarding/components/inapp/SurveyRole.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
166
apps/web/app/(app)/onboarding/components/onboarding.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
apps/web/app/(app)/onboarding/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// util.js
|
||||
export const handleTabNavigation = (fieldsetRef, setSelectedChoice) => (event) => {
|
||||
if (event.key !== "Tab") {
|
||||
return;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export const questionTypes: TSurveyQuestionType[] = [
|
||||
subheader: "Who? Who? Who?",
|
||||
placeholder: "Type your answer here...",
|
||||
longAnswer: true,
|
||||
inputType: "text",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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`} />;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
BIN
apps/web/images/onboarding-churn.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
apps/web/images/onboarding-collect-feedback.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
apps/web/images/onboarding-dance.gif
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
apps/web/images/onboarding-in-app-survey.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
apps/web/images/onboarding-link-survey.webp
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
apps/web/images/onboarding-lost.gif
Normal file
|
After Width: | Height: | Size: 415 KiB |
BIN
apps/web/images/onboarding-nps.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
@@ -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/);
|
||||
|
||||
@@ -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/);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
BIN
apps/web/public/onboarding/meme.png
Normal file
|
After Width: | Height: | Size: 202 KiB |
@@ -7,7 +7,7 @@
|
||||
"maxDuration": 10,
|
||||
"memory": 300
|
||||
},
|
||||
"app/**/*.ts": {
|
||||
"**/*.ts": {
|
||||
"maxDuration": 10,
|
||||
"memory": 512
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
21
packages/ui/CodeBlock/style.css
Normal file
@@ -0,0 +1,21 @@
|
||||
pre {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #e2e8f0 #ffffff;
|
||||
}
|
||||
|
||||
pre::-webkit-scrollbar {
|
||||
width: 4px !important;
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
pre::-webkit-scrollbar-track {
|
||||
background: #e2e8f0;
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
pre::-webkit-scrollbar-thumb {
|
||||
background-color: #cbd5e1;
|
||||
border: 3px solid #cbd5e1;
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
48
packages/ui/OptionCard/index.tsx
Normal 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>
|
||||
);
|
||||
@@ -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"],
|
||||
},
|
||||
}
|
||||
|
||||