Add Onboarding Survey after User-Signup (#193)

* add onboarding survey after user signup

* add user flag finishedOnboarding to database and session

* fix submission capture endpoint to allow customer property update

---------

Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: knugget <johannes@knugget.de>
This commit is contained in:
Matti Nannt
2023-02-08 11:12:12 +01:00
committed by GitHub
parent 1fec6e34a9
commit 6bfc46042b
21 changed files with 685 additions and 70 deletions

View File

@@ -11,7 +11,7 @@
"dependencies": {
"@formbricks/charts": "workspace:*",
"@formbricks/ee": "workspace:*",
"@formbricks/react": "workspace:*",
"@formbricks/engine-react": "workspace:*",
"@formbricks/ui": "workspace:*",
"@headlessui/react": "^1.7.8",
"@heroicons/react": "^2.0.14",

View File

@@ -0,0 +1,27 @@
"use client";
import LoadingSpinner from "@/components/LoadingSpinner";
import { useMemberships } from "@/lib/memberships";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function BasePathPage() {
const { memberships, isErrorMemberships } = useMemberships();
const router = useRouter();
useEffect(() => {
if (memberships && memberships.length > 0) {
const organisationId = memberships[0].organisationId;
router.push(`/organisations/${organisationId}/forms`);
}
}, [memberships, router]);
if (isErrorMemberships) {
return <div>Something went wrong...</div>;
}
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
</div>
);
}

View File

@@ -0,0 +1,199 @@
export function LogoMark(props) {
return (
<svg
width="30"
height="45"
viewBox="0 0 101 150"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}>
<g clipPath="url(#clip0_2627_5881)">
<path
d="M0 101.547H40.4528V122C40.4528 133.046 31.4985 142 20.4528 142H20C8.9543 142 0 133.046 0 122V101.547Z"
fill="url(#paint0_linear_2627_5881)"
/>
<path
d="M0 54.7737H81.1321C92.1778 54.7737 101.132 63.728 101.132 74.7737V75.2265C101.132 86.2722 92.1778 95.2265 81.1321 95.2265H0V54.7737Z"
fill="url(#paint1_linear_2627_5881)"
/>
<path
d="M0 28C0 16.9543 8.95431 8 20 8H81.1321C92.1778 8 101.132 16.9543 101.132 28V28.4528C101.132 39.4985 92.1778 48.4528 81.1321 48.4528H0V28Z"
fill="url(#paint2_linear_2627_5881)"
/>
<mask
id="mask0_2627_5881"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="0"
y="8"
width="102"
height="134">
<path
d="M0 101.547H40.4528V122C40.4528 133.046 31.4985 142 20.4528 142H20C8.9543 142 0 133.046 0 122V101.547Z"
fill="url(#paint3_linear_2627_5881)"
/>
<path
d="M0 54.7737H81.1321C92.1778 54.7737 101.132 63.728 101.132 74.7737V75.2265C101.132 86.2722 92.1778 95.2265 81.1321 95.2265H0V54.7737Z"
fill="url(#paint4_linear_2627_5881)"
/>
<path
d="M0 28C0 16.9543 8.95431 8 20 8H81.1321C92.1778 8 101.132 16.9543 101.132 28V28.4528C101.132 39.4985 92.1778 48.4528 81.1321 48.4528H0V28Z"
fill="url(#paint5_linear_2627_5881)"
/>
</mask>
<g mask="url(#mask0_2627_5881)">
<g filter="url(#filter0_d_2627_5881)">
<mask
id="mask1_2627_5881"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="0"
y="8"
width="102"
height="134">
<path
d="M0 101.547H40.4528V122C40.4528 133.046 31.4985 142 20.4528 142H20C8.9543 142 0 133.046 0 122V101.547Z"
fill="black"
fill-opacity="0.1"
/>
<path
d="M0 28C0 16.9543 8.95431 8 20 8H81.1321C92.1778 8 101.132 16.9543 101.132 28V28.4528C101.132 39.4985 92.1778 48.4528 81.1321 48.4528H0V28Z"
fill="black"
fill-opacity="0.1"
/>
<path
d="M0 54.7737H81.1321C92.1778 54.7737 101.132 63.728 101.132 74.7737V75.2265C101.132 86.2722 92.1778 95.2265 81.1321 95.2265H0V54.7737Z"
fill="black"
fill-opacity="0.1"
/>
</mask>
<g mask="url(#mask1_2627_5881)">
<path
d="M2.12216 -26.8434C17.9685 -42.3091 58.1507 -26.8434 58.1507 -26.8434H2.12216C-1.76989 -23.0449 -4.19388 -17.3804 -4.19388 -9.16251C-4.19388 32.5141 40.9522 47.6695 40.9522 76.7169C40.9522 105.152 -2.3106 122.695 -4.13455 161.333H58.1507C58.1507 161.333 -4.19388 204.273 -4.19388 163.859C-4.19388 163.007 -4.17382 162.165 -4.13455 161.333H-31.604L-26.2295 -26.8434H2.12216Z"
fill="black"
fill-opacity="0.1"
/>
</g>
</g>
<g filter="url(#filter1_f_2627_5881)">
<circle cx="-12.6414" cy="124.302" r="37.9245" fill="#00C4B8" />
</g>
<g filter="url(#filter2_f_2627_5881)">
<circle cx="-12.6414" cy="28.2265" r="37.9245" fill="#00C4B8" />
</g>
</g>
</g>
<defs>
<filter
id="filter0_d_2627_5881"
x="-2"
y="-4"
width="82.1506"
height="158"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="10" />
<feGaussianBlur stdDeviation="6" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2627_5881" />
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2627_5881" result="shape" />
</filter>
<filter
id="filter1_f_2627_5881"
x="-70.5659"
y="66.3774"
width="115.849"
height="115.849"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="10" result="effect1_foregroundBlur_2627_5881" />
</filter>
<filter
id="filter2_f_2627_5881"
x="-70.5659"
y="-29.698"
width="115.849"
height="115.849"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="10" result="effect1_foregroundBlur_2627_5881" />
</filter>
<linearGradient
id="paint0_linear_2627_5881"
x1="40.6287"
y1="121.041"
x2="-0.003482"
y2="121.205"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00E6CA" />
<stop offset="1" stopColor="#00C4B8" />
</linearGradient>
<linearGradient
id="paint1_linear_2627_5881"
x1="101.572"
y1="74.2673"
x2="1.27605e-08"
y2="75.2932"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00E6CA" />
<stop offset="1" stopColor="#00C4B8" />
</linearGradient>
<linearGradient
id="paint2_linear_2627_5881"
x1="101.572"
y1="27.4936"
x2="1.27605e-08"
y2="28.5195"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00E6CA" />
<stop offset="1" stopColor="#00C4B8" />
</linearGradient>
<linearGradient
id="paint3_linear_2627_5881"
x1="40.6287"
y1="121.041"
x2="-0.003482"
y2="121.205"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00FFE1" />
<stop offset="1" stopColor="#01E0C6" />
</linearGradient>
<linearGradient
id="paint4_linear_2627_5881"
x1="101.572"
y1="74.2673"
x2="1.27605e-08"
y2="75.2932"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00FFE1" />
<stop offset="1" stopColor="#01E0C6" />
</linearGradient>
<linearGradient
id="paint5_linear_2627_5881"
x1="101.572"
y1="27.4936"
x2="1.27605e-08"
y2="28.5195"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00FFE1" />
<stop offset="1" stopColor="#01E0C6" />
</linearGradient>
<clipPath id="clip0_2627_5881">
<rect width="101" height="150" fill="white" />
</clipPath>
</defs>
</svg>
);
}

View File

@@ -34,14 +34,14 @@ export const SignupForm = () => {
return (
<>
{error && (
<div className="absolute top-10 rounded-md bg-sky-50 p-4">
<div className="absolute top-10 rounded-md bg-teal-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<XCircleIcon className="h-5 w-5 text-sky-400" aria-hidden="true" />
<XCircleIcon className="h-5 w-5 text-teal-400" aria-hidden="true" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-sky-800">An error occurred when logging you in</h3>
<div className="mt-2 text-sm text-sky-700">
<h3 className="text-sm font-medium text-teal-800">An error occurred when logging you in</h3>
<div className="mt-2 text-sm text-teal-700">
<p className="space-y-1 whitespace-pre-wrap">{error}</p>
</div>
</div>
@@ -101,7 +101,7 @@ export const SignupForm = () => {
<div className="mt-3 text-center text-xs text-gray-600">
Already have an account?{" "}
<Link href="/auth/signin" className="text-sky hover:text-sky-600">
<Link href="/auth/signin" className="text-brand hover:text-brand-light">
Log in.
</Link>
</div>
@@ -111,7 +111,7 @@ export const SignupForm = () => {
<br />
{process.env.NEXT_PUBLIC_TERMS_URL && (
<a
className="text-sky underline hover:text-sky-600"
className="text-brand hover:text-brand-light underline"
href={process.env.NEXT_PUBLIC_TERMS_URL}
rel="noreferrer"
target="_blank">
@@ -121,7 +121,7 @@ export const SignupForm = () => {
{process.env.NEXT_PUBLIC_TERMS_URL && process.env.NEXT_PUBLIC_PRIVACY_URL && <span> and </span>}
{process.env.NEXT_PUBLIC_PRIVACY_URL && (
<a
className="text-sky underline hover:text-sky-600"
className="text-brand hover:text-brand-light underline"
href={process.env.NEXT_PUBLIC_PRIVACY_URL}
rel="noreferrer"
target="_blank">

View File

@@ -40,7 +40,7 @@ export default function LayoutApp({ children }) {
if (!session) {
router.push(`/auth/signin?callbackUrl=${encodeURIComponent(window.location.href)}`);
return <div></div>;
return <LoadingSpinner />;
}
if (isLoadingMemberships) {
@@ -55,6 +55,12 @@ export default function LayoutApp({ children }) {
return <div>Error loading ressources. Maybe you don&lsquo;t have enough access rights</div>;
}
if (session && session.user.finishedOnboarding === false && router.pathname !== "/me/onboarding") {
// use timeout to prevent flash of content and resulting errors
router.push("/me/onboarding");
return <LoadingSpinner />;
}
return (
<>
<Head>

View File

@@ -4,7 +4,6 @@ import LoadingSpinner from "@/components/LoadingSpinner";
import Modal from "@/components/Modal";
import { createApiKey, deleteApiKey, useApiKeys } from "@/lib/apiKeys";
import { convertDateTimeString } from "@/lib/utils";
import { Form, Submit, Text } from "@formbricks/react";
import { Button } from "@formbricks/ui";
import { useState } from "react";
@@ -127,17 +126,27 @@ export default function ProfileSettingsPage() {
Create a Personal API Key
</h2>
<hr className="my-4 w-full text-gray-400" />
<Form
onSubmit={async ({ submission }) => {
const apiKey = await createApiKey(submission.data);
<form
onSubmit={async (e: any) => {
e.preventDefault();
const apiKey = await createApiKey({ label: e.target.label.value });
mutateApiKeys([...JSON.parse(JSON.stringify(apiKeys)), apiKey], false);
setOpenNewApiKeyModal(false);
}}>
<Text
name="label"
placeholder="Label, e.g. Github"
inputClassName="focus:border-brand focus:ring-brand block w-full rounded-md border-gray-300 shadow-sm sm:text-sm"
/>
<div>
<label htmlFor="label" className="block text-sm font-medium text-gray-700">
Email
</label>
<div className="mt-1">
<input
type="text"
name="label"
id="label"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
placeholder="Label, e.g. Github"
/>
</div>
</div>
<p className="mt-3 text-sm text-gray-800">
Key value will only ever be shown once, immediately after creation. Copy it to your destination
right away.
@@ -146,13 +155,9 @@ export default function ProfileSettingsPage() {
<Button variant="secondary" className="mr-2">
Cancel
</Button>
<Submit
name="submit"
label="Create"
inputClassName="inline-flex items-center appearance-none px-6 py-2 text-sm font-medium rounded-xl relative text-slate-900 bg-gradient-to-b from-brand-light to-brand-dark hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-slate-900"
/>
<Button type="submit">Create</Button>
</div>
</Form>
</form>
</Modal>
)}
</div>

View File

@@ -0,0 +1,12 @@
const ForwardToApp = () => {
return (
<div className="text-center text-sm text-slate-700">
Thanks you 🕺
<br />
<br />
Redirecting to app...
</div>
);
};
export default ForwardToApp;

View File

@@ -0,0 +1,92 @@
import { RadioGroup } from "@headlessui/react";
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import clsx from "clsx";
import { useEffect } from "react";
import { Controller, useWatch } from "react-hook-form";
interface IconRadioProps {
element: any;
field: any;
control: any;
onSubmit: () => void;
disabled: boolean;
}
export default function IconRadio({ element, control, onSubmit, disabled }: IconRadioProps) {
const value = useWatch({
control,
name: element.name!!,
});
useEffect(() => {
if (value && !disabled) {
onSubmit();
}
}, [value, onSubmit, disabled]);
return (
<Controller
name={element.name!}
control={control}
rules={{ required: true }}
render={({ field }: { field: any }) => (
<RadioGroup className="flex flex-col justify-center" {...field}>
<RadioGroup.Label className="max-w-sm pb-1 text-center font-medium text-slate-600">
{element.label}
</RadioGroup.Label>
<div className="mx-auto -mt-3 mb-3 text-center text-sm text-slate-500 dark:text-slate-300 md:max-w-lg">
{element.help}
</div>
<div className="mt-4 grid w-full grid-cols-1 gap-y-2 sm:gap-x-4">
{element.options &&
element.options.map((option) => (
<RadioGroup.Option
key={option.value}
value={option.value}
className={({ checked, active }) =>
clsx(
checked ? "border-transparent" : "border-slate-200 ",
/* active ? "border-brand ring-brand ring-2" : "", */
"relative flex cursor-pointer rounded-lg border bg-slate-50 py-2 shadow-sm transition-all ease-in-out hover:scale-105 focus:outline-none"
)
}>
{({ checked, active }) => (
<>
<div className="flex flex-1 flex-col justify-center text-slate-500 hover:text-slate-700 ">
{option.frontend?.icon && (
<option.frontend.icon
className="text-brand mx-auto mb-3 h-8 w-8"
aria-hidden="true"
/>
)}
<RadioGroup.Label as="span" className="mx-auto text-sm font-medium ">
{option.label}
</RadioGroup.Label>
</div>
<CheckCircleIcon
className={clsx(
!checked ? "invisible" : "",
"text-brand absolute -right-2 -top-2 z-10 h-5 w-5 rounded-full bg-white"
)}
aria-hidden="true"
/>
<span
className={clsx(
active ? "border" : "border-2",
checked ? "border-brand" : "border-transparent",
"pointer-events-none absolute -inset-px rounded-lg"
)}
aria-hidden="true"
/>
</>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
)}
/>
);
}

View File

@@ -0,0 +1,71 @@
import { LogoMark } from "@/components/LogoMark";
import OnboardingSurvey from "@/components/onboarding/OnboardingSurvey";
import { Dialog, Transition } from "@headlessui/react";
import { Fragment, useEffect, useState } from "react";
export default function OnboardingPage() {
const [loading, setLoading] = useState(false);
useEffect(() => {
const timeoutId = setTimeout(() => {
setLoading(true);
}, 5000);
return () => {
clearTimeout(timeoutId);
};
}, []);
return (
<Transition.Root show={true} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={() => {}}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-gray-200 bg-opacity-75 backdrop-blur transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6">
<div className="bg-brand/10 border-brand mb-4 flex h-48 w-full flex-col items-center justify-center rounded-xl border py-5">
{loading ? (
<LogoMark />
) : (
<span className="relative flex h-5 w-5 pt-1">
<span className="bg-brand/75 absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
<span className="bg-brand relative inline-flex h-5 w-5 rounded-full"></span>
</span>
)}
{loading ? (
<>
<p className="text-brand pt-4 text-xs">Ready to roll 🤸</p>
<p className="text-brand pt-4 text-xs">
Please answer the following questions to continue
</p>
</>
) : (
<p className="text-brand pt-4 text-xs">We&apos;re getting Formbricks ready for you.</p>
)}
</div>
<OnboardingSurvey />
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}

View File

@@ -0,0 +1,134 @@
import { capturePosthogEvent } from "@/lib/posthog";
import { FormbricksEngine } from "@formbricks/engine-react";
import { useSession } from "next-auth/react";
import { useEffect } from "react";
import LoadingSpinner from "../LoadingSpinner";
import ForwardToApp from "./ForwardToApp";
import IconRadio from "./IconRadio";
const OnboardingSurvey = () => {
const { data: session, status } = useSession();
useEffect(() => {
if (session.user.finishedOnboarding) {
window.location.replace("/");
}
}, [session]);
if (status === "loading") return <LoadingSpinner />;
const formId =
process.env.NODE_ENV === "production" ? "cldu60z5d0000mm0hq7k0ducf" : "cldvi1rzq0006oy0hg0ahsedi";
return (
<FormbricksEngine
offline={true}
onFinished={async ({ submission }) => {
console.log({
email: session.user.email,
name: session.user.name,
lastUserContact: submission.lastUserContact,
hardestPartInUserResearch: submission.hardestPartInUserResearch,
});
// send submission to formbricks
const res = await fetch(`https://app.formbricks.com/api/capture/forms/${formId}/submissions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
customer: {
email: session.user.email,
name: session.user.name,
lastUserContact: submission.lastUserContact,
hardestPartInUserResearch: submission.hardestPartInUserResearch,
},
data: submission,
}),
});
if (!res.ok) {
// send event to posthog
capturePosthogEvent("system", "onboarding form error occured", { error: await res.text() });
}
// update user in database
await fetch("/api/users/me", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ finishedOnboarding: true }),
});
// redirect to app
window.location.replace("/");
}}
schema={{
config: {
progressBar: false,
},
pages: [
{
id: "rolePage",
config: {
autoSubmit: true,
},
elements: [
{
id: "hardestPartInUserResearch",
type: "radio",
label: "The hardest part about user research is...",
/* help: "Helps us focus on what you need most.", */
name: "hardestPartInUserResearch",
options: [
{ label: "Not sure where to start", value: "notSureWhereToStart" },
{
label: "Unresponsive users",
value: "unresponsiveUsers",
},
{ label: "Small user base", value: "smallUserBase" },
{ label: "Doing it consistently", value: "consistency" },
{ label: "Implementing methods", value: "Implementation" },
],
component: IconRadio,
},
],
},
{
id: "targetGroupPage",
config: {
autoSubmit: true,
},
elements: [
{
id: "lastUserContact",
type: "radio",
label: "When was the last time you talked to one of your users?",
/* help: "(honest answers only)", */
name: "lastUserContact",
options: [
{ label: "Today", value: "today" },
{
label: "Yesterday",
value: "yesterday",
},
{ label: "This week", value: "thisWeek" },
{ label: "This month", value: "thisMonth" },
{ label: "I should do that more often", value: "iShouldDoThatMoreOften" },
],
component: IconRadio,
},
],
},
{
id: "onboardingDone",
endScreen: true,
elements: [
{
id: "forward",
type: "html",
component: ForwardToApp,
},
],
},
],
}}
/>
);
};
export default OnboardingSurvey;

View File

@@ -7,6 +7,7 @@ import CredentialsProvider from "next-auth/providers/credentials";
import GitHubProvider from "next-auth/providers/github";
import { verifyPassword } from "../../../lib/auth";
import { verifyToken } from "../../../lib/jwt";
import { type } from "os";
export const authOptions: NextAuthOptions = {
providers: [
@@ -132,6 +133,7 @@ export const authOptions: NextAuthOptions = {
select: {
id: true,
name: true,
finishedOnboarding: true,
},
});
@@ -140,14 +142,17 @@ export const authOptions: NextAuthOptions = {
}
return {
...existingUser,
...token,
...existingUser,
};
},
async session({ session, token }) {
// @ts-ignore
session.user.id = token.id;
session.user.name = token.name;
if (typeof token.finishedOnboarding == "boolean") {
session.user.finishedOnboarding = token.finishedOnboarding;
}
return session;
},

View File

@@ -44,22 +44,40 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
const customerEmail = submission.customer.email;
const customerData = { ...submission.customer };
delete customerData.email;
// create or link customer
event.data.customer = {
connectOrCreate: {
const existingCustomer = await prisma.customer.findUnique({
where: {
email_organisationId: {
email: submission.customer.email,
organisationId: form.organisationId,
},
},
});
if (existingCustomer) {
// update customer
await prisma.customer.update({
where: {
email_organisationId: {
email: submission.customer.email,
organisationId: form.organisationId,
},
},
data: {
data: { ...existingCustomer.data, ...customerData },
},
});
event.data.customer = {
connect: { organisationId_email: { email: customerEmail, organisationId: form.organisationId } },
};
} else {
// create customer
event.data.customer = {
create: {
email: customerEmail,
organisation: { connect: { id: form.organisationId } },
data: customerData,
},
},
};
};
}
}
// create form in db
@@ -67,7 +85,9 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
if (submission.finished) {
pipelineEvents.push("submissionFinished");
}
// create submission
const submissionResult = await prisma.submission.create(event);
// run pipelines
await runPipelines(pipelineEvents, form, submission, submissionResult);
// tracking
capturePosthogEvent(form.organisationId, "submission received", {

View File

@@ -26,6 +26,16 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
},
});
return res.json(user);
} // GET /api/users/me
// Get the current user
else if (req.method === "PUT") {
const user = await prisma.user.update({
where: {
email: session.email,
},
data: req.body,
});
return res.json(user);
}
// Unknown HTTP Method

View File

@@ -1,35 +1,12 @@
"use client";
import BasePathPage from "@/components/BasePathPage";
import LayoutApp from "@/components/layout/LayoutApp";
import { useMemberships } from "@/lib/memberships";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import LoadingSpinner from "@/components/LoadingSpinner";
export default function ProjectsPage() {
const { data: session } = useSession();
const { memberships, isErrorMemberships } = useMemberships();
const router = useRouter();
useEffect(() => {
if (session && memberships && memberships.length > 0) {
const organisationId = memberships[0].organisationId;
router.push(`/organisations/${organisationId}/forms`);
}
if (!session) {
router.push(`/auth/signin?callbackUrl=${encodeURIComponent(window.location.href)}`);
}
}, [memberships, router, session]);
if (isErrorMemberships) {
return <div>Something went wrong...</div>;
}
return (
<LayoutApp>
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
</div>
<BasePathPage />
</LayoutApp>
);
}

View File

@@ -0,0 +1,12 @@
"use client";
import LayoutApp from "@/components/layout/LayoutApp";
import OnboardingPage from "@/components/onboarding/OnboardingPage";
export default function Verify() {
return (
<LayoutApp>
<OnboardingPage />
</LayoutApp>
);
}

16
apps/web/src/types/next-auth.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
import NextAuth from "next-auth";
declare module "next-auth" {
/**
* Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
*/
interface Session {
user: {
/** The user's postal address. */
email: string;
name: string;
finishedOnboarding: boolean;
image?: StaticImageData;
};
}
}

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "finishedOnboarding" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -164,4 +164,5 @@ model User {
organisations Membership[]
accounts Account[]
apiKeys ApiKey[]
finishedOnboarding Boolean @default(false)
}

View File

@@ -1,4 +1,3 @@
import clsx from "clsx";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { FormPage } from "../types";
@@ -7,13 +6,15 @@ interface FormProps {
page: FormPage;
onSkip: () => void;
onPageSubmit: (submission: any) => void;
onFinished: ({ submission }: any) => void;
onFinished: ({ submission, schema }: any) => void;
submission: any;
setSubmission: (v: any) => void;
finished: boolean;
formbricksUrl: string;
formId: string;
formbricksUrl?: string;
formId?: string;
schema: any;
customer: any;
offline?: boolean;
}
export function EnginePage({
@@ -26,6 +27,8 @@ export function EnginePage({
formbricksUrl,
formId,
schema,
customer,
offline,
}: FormProps) {
const [submissionId, setSubmissionId] = useState<string>();
const {
@@ -44,17 +47,34 @@ export function EnginePage({
useEffect(() => {
if (page.endScreen) {
fetch(`${formbricksUrl}/api/capture/forms/${formId}/submissions/${submissionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ finished: true }),
});
onFinished({ submission });
if (!offline) {
if (!formbricksUrl) {
throw new Error("Formbricks URL not provided");
}
if (!formId) {
throw new Error("Form ID not provided");
}
fetch(`${formbricksUrl}/api/capture/forms/${formId}/submissions/${submissionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ finished: true }),
});
}
onFinished({ submission, schema });
}
}, [page, formId, formbricksUrl, submissionId]);
const sendToFormbricks = async (partialSubmission: any) => {
const submissionBody: any = { data: partialSubmission };
if (offline) {
return;
}
if (!formbricksUrl) {
throw new Error("Formbricks URL not provided");
}
if (!formId) {
throw new Error("Form ID not provided");
}
const submissionBody: any = { data: partialSubmission, customer };
if (page.config?.addFieldsToCustomer && Array.isArray(page.config?.addFieldsToCustomer)) {
for (const field of page.config?.addFieldsToCustomer) {
if (field in partialSubmission) {

View File

@@ -4,18 +4,22 @@ import { EnginePage } from "./EnginePage";
interface FormProps {
schema: Form;
formbricksUrl: string;
formId: string;
formbricksUrl?: string;
formId?: string;
customer?: any;
onFinished?: ({ submission }: any) => void;
onPageSubmit?: ({ page }: any) => void;
offline?: boolean;
}
export function FormbricksEngine({
schema,
formbricksUrl,
formId,
customer = {},
onFinished = () => {},
onPageSubmit = () => {},
offline = false,
}: FormProps) {
if (!schema) {
console.error("Formbricks Engine: No form provided");
@@ -77,6 +81,8 @@ export function FormbricksEngine({
formbricksUrl={formbricksUrl}
formId={formId}
schema={cleanedSchema}
customer={customer}
offline={offline}
/>
</div>
);

4
pnpm-lock.yaml generated
View File

@@ -188,7 +188,7 @@ importers:
'@formbricks/charts': workspace:*
'@formbricks/database': workspace:*
'@formbricks/ee': workspace:*
'@formbricks/react': workspace:*
'@formbricks/engine-react': workspace:*
'@formbricks/tailwind-config': workspace:*
'@formbricks/tsconfig': workspace:*
'@formbricks/ui': workspace:*
@@ -224,7 +224,7 @@ importers:
dependencies:
'@formbricks/charts': link:../../packages/charts
'@formbricks/ee': link:../../packages/ee
'@formbricks/react': link:../../packages/react
'@formbricks/engine-react': link:../../packages/engine-react
'@formbricks/ui': link:../../packages/ui
'@headlessui/react': 1.7.8_biqbaboplfbrettd7655fr4n2y
'@heroicons/react': 2.0.14_react@18.2.0