diff --git a/apps/web/package.json b/apps/web/package.json index 4861314097..7d972f924a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/components/BasePathPage.tsx b/apps/web/src/components/BasePathPage.tsx new file mode 100644 index 0000000000..afe5951dd5 --- /dev/null +++ b/apps/web/src/components/BasePathPage.tsx @@ -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
Something went wrong...
; + } + return ( +
+ +
+ ); +} diff --git a/apps/web/src/components/LogoMark.tsx b/apps/web/src/components/LogoMark.tsx new file mode 100644 index 0000000000..ed61b32d81 --- /dev/null +++ b/apps/web/src/components/LogoMark.tsx @@ -0,0 +1,199 @@ +export function LogoMark(props) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/web/src/components/auth/SignupForm.tsx b/apps/web/src/components/auth/SignupForm.tsx index 634d64502c..414450d7fd 100644 --- a/apps/web/src/components/auth/SignupForm.tsx +++ b/apps/web/src/components/auth/SignupForm.tsx @@ -34,14 +34,14 @@ export const SignupForm = () => { return ( <> {error && ( -
+
-
-

An error occurred when logging you in

-
+

An error occurred when logging you in

+

{error}

@@ -101,7 +101,7 @@ export const SignupForm = () => {
Already have an account?{" "} - + Log in.
@@ -111,7 +111,7 @@ export const SignupForm = () => {
{process.env.NEXT_PUBLIC_TERMS_URL && ( @@ -121,7 +121,7 @@ export const SignupForm = () => { {process.env.NEXT_PUBLIC_TERMS_URL && process.env.NEXT_PUBLIC_PRIVACY_URL && and } {process.env.NEXT_PUBLIC_PRIVACY_URL && ( diff --git a/apps/web/src/components/layout/LayoutApp.tsx b/apps/web/src/components/layout/LayoutApp.tsx index c923161956..4a64bcf1f2 100644 --- a/apps/web/src/components/layout/LayoutApp.tsx +++ b/apps/web/src/components/layout/LayoutApp.tsx @@ -40,7 +40,7 @@ export default function LayoutApp({ children }) { if (!session) { router.push(`/auth/signin?callbackUrl=${encodeURIComponent(window.location.href)}`); - return
; + return ; } if (isLoadingMemberships) { @@ -55,6 +55,12 @@ export default function LayoutApp({ children }) { return
Error loading ressources. Maybe you don‘t have enough access rights
; } + 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 ; + } + return ( <> diff --git a/apps/web/src/components/me/ProfileSettingsPage.tsx b/apps/web/src/components/me/ProfileSettingsPage.tsx index 0cca510d69..e5e5eef022 100644 --- a/apps/web/src/components/me/ProfileSettingsPage.tsx +++ b/apps/web/src/components/me/ProfileSettingsPage.tsx @@ -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
-
{ - const apiKey = await createApiKey(submission.data); + { + e.preventDefault(); + const apiKey = await createApiKey({ label: e.target.label.value }); mutateApiKeys([...JSON.parse(JSON.stringify(apiKeys)), apiKey], false); setOpenNewApiKeyModal(false); }}> - +
+ +
+ +
+

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() { - +

- + )}
diff --git a/apps/web/src/components/onboarding/ForwardToApp.tsx b/apps/web/src/components/onboarding/ForwardToApp.tsx new file mode 100644 index 0000000000..816fef3af2 --- /dev/null +++ b/apps/web/src/components/onboarding/ForwardToApp.tsx @@ -0,0 +1,12 @@ +const ForwardToApp = () => { + return ( +
+ Thanks you 🕺 +
+
+ Redirecting to app... +
+ ); +}; + +export default ForwardToApp; diff --git a/apps/web/src/components/onboarding/IconRadio.tsx b/apps/web/src/components/onboarding/IconRadio.tsx new file mode 100644 index 0000000000..f897dcd8b0 --- /dev/null +++ b/apps/web/src/components/onboarding/IconRadio.tsx @@ -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 ( + ( + + + {element.label} + +
+ {element.help} +
+ +
+ {element.options && + element.options.map((option) => ( + + 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 }) => ( + <> +
+ {option.frontend?.icon && ( +
+ +
+ ))} +
+
+ )} + /> + ); +} diff --git a/apps/web/src/components/onboarding/OnboardingPage.tsx b/apps/web/src/components/onboarding/OnboardingPage.tsx new file mode 100644 index 0000000000..8120b3a648 --- /dev/null +++ b/apps/web/src/components/onboarding/OnboardingPage.tsx @@ -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 ( + + {}}> + +
+ + +
+
+ + +
+ {loading ? ( + + ) : ( + + + + + )} + {loading ? ( + <> +

Ready to roll 🤸

+

+ Please answer the following questions to continue +

+ + ) : ( +

We're getting Formbricks ready for you.

+ )} +
+ +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/onboarding/OnboardingSurvey.tsx b/apps/web/src/components/onboarding/OnboardingSurvey.tsx new file mode 100644 index 0000000000..79821e0330 --- /dev/null +++ b/apps/web/src/components/onboarding/OnboardingSurvey.tsx @@ -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 ; + + const formId = + process.env.NODE_ENV === "production" ? "cldu60z5d0000mm0hq7k0ducf" : "cldvi1rzq0006oy0hg0ahsedi"; + + return ( + { + 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; diff --git a/apps/web/src/pages/api/auth/[...nextauth].ts b/apps/web/src/pages/api/auth/[...nextauth].ts index d140c2155d..0fc492e63f 100644 --- a/apps/web/src/pages/api/auth/[...nextauth].ts +++ b/apps/web/src/pages/api/auth/[...nextauth].ts @@ -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; }, diff --git a/apps/web/src/pages/api/capture/forms/[formId]/submissions/index.ts b/apps/web/src/pages/api/capture/forms/[formId]/submissions/index.ts index c2bf8530b7..d9bd13aa85 100644 --- a/apps/web/src/pages/api/capture/forms/[formId]/submissions/index.ts +++ b/apps/web/src/pages/api/capture/forms/[formId]/submissions/index.ts @@ -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", { diff --git a/apps/web/src/pages/api/users/me/index.ts b/apps/web/src/pages/api/users/me/index.ts index 4b38973362..00eb5790e7 100644 --- a/apps/web/src/pages/api/users/me/index.ts +++ b/apps/web/src/pages/api/users/me/index.ts @@ -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 diff --git a/apps/web/src/pages/index.tsx b/apps/web/src/pages/index.tsx index dfef861c94..12a59e5572 100644 --- a/apps/web/src/pages/index.tsx +++ b/apps/web/src/pages/index.tsx @@ -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
Something went wrong...
; - } return ( -
- -
+
); } diff --git a/apps/web/src/pages/me/onboarding/index.tsx b/apps/web/src/pages/me/onboarding/index.tsx new file mode 100644 index 0000000000..6c38523d12 --- /dev/null +++ b/apps/web/src/pages/me/onboarding/index.tsx @@ -0,0 +1,12 @@ +"use client"; + +import LayoutApp from "@/components/layout/LayoutApp"; +import OnboardingPage from "@/components/onboarding/OnboardingPage"; + +export default function Verify() { + return ( + + + + ); +} diff --git a/apps/web/src/types/next-auth.d.ts b/apps/web/src/types/next-auth.d.ts new file mode 100644 index 0000000000..f56e43bffc --- /dev/null +++ b/apps/web/src/types/next-auth.d.ts @@ -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; + }; + } +} diff --git a/packages/database/prisma/migrations/20230207100310_add_finished_onboarding_flag_to_user_model/migration.sql b/packages/database/prisma/migrations/20230207100310_add_finished_onboarding_flag_to_user_model/migration.sql new file mode 100644 index 0000000000..987f216201 --- /dev/null +++ b/packages/database/prisma/migrations/20230207100310_add_finished_onboarding_flag_to_user_model/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "finishedOnboarding" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 7bc282624f..248a184483 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -164,4 +164,5 @@ model User { organisations Membership[] accounts Account[] apiKeys ApiKey[] + finishedOnboarding Boolean @default(false) } diff --git a/packages/engine-react/src/components/EnginePage.tsx b/packages/engine-react/src/components/EnginePage.tsx index b6474fc04d..d94dc2d326 100644 --- a/packages/engine-react/src/components/EnginePage.tsx +++ b/packages/engine-react/src/components/EnginePage.tsx @@ -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(); 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) { diff --git a/packages/engine-react/src/components/FormbricksEngine.tsx b/packages/engine-react/src/components/FormbricksEngine.tsx index 0875f2b272..a9b17a7fe4 100644 --- a/packages/engine-react/src/components/FormbricksEngine.tsx +++ b/packages/engine-react/src/components/FormbricksEngine.tsx @@ -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} />
); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 351a235049..f7d7d0ada0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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