mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-20 17:31:27 -05:00
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:
@@ -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",
|
||||
|
||||
27
apps/web/src/components/BasePathPage.tsx
Normal file
27
apps/web/src/components/BasePathPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
199
apps/web/src/components/LogoMark.tsx
Normal file
199
apps/web/src/components/LogoMark.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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‘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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
12
apps/web/src/components/onboarding/ForwardToApp.tsx
Normal file
12
apps/web/src/components/onboarding/ForwardToApp.tsx
Normal 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;
|
||||
92
apps/web/src/components/onboarding/IconRadio.tsx
Normal file
92
apps/web/src/components/onboarding/IconRadio.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
71
apps/web/src/components/onboarding/OnboardingPage.tsx
Normal file
71
apps/web/src/components/onboarding/OnboardingPage.tsx
Normal 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're getting Formbricks ready for you.</p>
|
||||
)}
|
||||
</div>
|
||||
<OnboardingSurvey />
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
}
|
||||
134
apps/web/src/components/onboarding/OnboardingSurvey.tsx
Normal file
134
apps/web/src/components/onboarding/OnboardingSurvey.tsx
Normal 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;
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
12
apps/web/src/pages/me/onboarding/index.tsx
Normal file
12
apps/web/src/pages/me/onboarding/index.tsx
Normal 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
16
apps/web/src/types/next-auth.d.ts
vendored
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "finishedOnboarding" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -164,4 +164,5 @@ model User {
|
||||
organisations Membership[]
|
||||
accounts Account[]
|
||||
apiKeys ApiKey[]
|
||||
finishedOnboarding Boolean @default(false)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
4
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user