diff --git a/.env.example b/.env.example
index 8a2ff7b4aa..fbde111339 100644
--- a/.env.example
+++ b/.env.example
@@ -162,9 +162,6 @@ ENTERPRISE_LICENSE_KEY=
# DEFAULT_ORGANIZATION_ID=
# DEFAULT_ORGANIZATION_ROLE=admin
-# set to 1 to skip onboarding for new users
-# ONBOARDING_DISABLED=1
-
# Send new users to customer.io
# CUSTOMER_IO_API_KEY=
# CUSTOMER_IO_SITE_ID=
diff --git a/.github/workflows/kamal-deploy.yml b/.github/workflows/kamal-deploy.yml
index c817507938..a60a1fd1d0 100644
--- a/.github/workflows/kamal-deploy.yml
+++ b/.github/workflows/kamal-deploy.yml
@@ -57,7 +57,6 @@ jobs:
AIRTABLE_CLIENT_ID: ${{ secrets.AIRTABLE_CLIENT_ID }}
ENTERPRISE_LICENSE_KEY: ${{ secrets.ENTERPRISE_LICENSE_KEY }}
DEFAULT_ORGANIZATION_ID: ${{ vars.DEFAULT_ORGANIZATION_ID }}
- ONBOARDING_DISABLED: ${{ vars.ONBOARDING_DISABLED }}
CUSTOMER_IO_API_KEY: ${{ secrets.CUSTOMER_IO_API_KEY }}
CUSTOMER_IO_SITE_ID: ${{ secrets.CUSTOMER_IO_SITE_ID }}
NEXT_PUBLIC_POSTHOG_API_KEY: ${{ vars.NEXT_PUBLIC_POSTHOG_API_KEY }}
diff --git a/.github/workflows/kamal-setup.yml b/.github/workflows/kamal-setup.yml
index 5dc3d81ea8..8c40f3a575 100644
--- a/.github/workflows/kamal-setup.yml
+++ b/.github/workflows/kamal-setup.yml
@@ -54,7 +54,6 @@ jobs:
AIRTABLE_CLIENT_ID: ${{ secrets.AIRTABLE_CLIENT_ID }}
ENTERPRISE_LICENSE_KEY: ${{ secrets.ENTERPRISE_LICENSE_KEY }}
DEFAULT_ORGANIZATION_ID: ${{ vars.DEFAULT_ORGANIZATION_ID }}
- ONBOARDING_DISABLED: ${{ vars.ONBOARDING_DISABLED }}
CUSTOMER_IO_API_KEY: ${{ secrets.CUSTOMER_IO_API_KEY }}
CUSTOMER_IO_SITE_ID: ${{ secrets.CUSTOMER_IO_SITE_ID }}
NEXT_PUBLIC_POSTHOG_API_KEY: ${{ vars.NEXT_PUBLIC_POSTHOG_API_KEY }}
diff --git a/apps/docs/app/self-hosting/configuration/page.mdx b/apps/docs/app/self-hosting/configuration/page.mdx
index 365172ded2..e43e2fbf9c 100644
--- a/apps/docs/app/self-hosting/configuration/page.mdx
+++ b/apps/docs/app/self-hosting/configuration/page.mdx
@@ -52,7 +52,6 @@ These variables are present inside your machineโs docker-compose file. Restart
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
| DEFAULT_ORGANIZATION_ID | Automatically assign new users to a specific organization when joining | optional | |
| DEFAULT_ORGANIZATION_ROLE | Role of the user in the default organization. | optional | admin |
-| ONBOARDING_DISABLED | Disables onboarding for new users if set toย 1 | optional | |
| OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | |
| OIDC_CLIENT_ID | Client ID for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
| OIDC_CLIENT_SECRET | Secret for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks.tsx
new file mode 100644
index 0000000000..1937531229
--- /dev/null
+++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+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 } from "react";
+import { cn } from "@formbricks/lib/cn";
+import { TEnvironment } from "@formbricks/types/environment";
+import { TProductConfigChannel } from "@formbricks/types/product";
+import { Button } from "@formbricks/ui/Button";
+import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
+
+interface ConnectWithFormbricksProps {
+ environment: TEnvironment;
+ webAppUrl: string;
+ widgetSetupCompleted: boolean;
+ channel: TProductConfigChannel;
+}
+
+export const ConnectWithFormbricks = ({
+ environment,
+ webAppUrl,
+ widgetSetupCompleted,
+ channel,
+}: ConnectWithFormbricksProps) => {
+ const router = useRouter();
+
+ const handleFinishOnboarding = async () => {
+ if (!widgetSetupCompleted) {
+ router.push(`/environments/${environment.id}/connect/invite`);
+ return;
+ }
+ router.push(`/environments/${environment.id}/surveys`);
+ };
+
+ useEffect(() => {
+ const handleVisibilityChange = async () => {
+ if (document.visibilityState === "visible") {
+ router.refresh();
+ }
+ };
+
+ document.addEventListener("visibilitychange", handleVisibilityChange);
+ return () => {
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+
+
+
+
+
+ {widgetSetupCompleted ? (
+
+
+
Connection successful โ
+
+ ) : (
+
+
+
Waiting for your signal...
+
+ )}
+
+
+
+
+ );
+};
diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/InviteOrganizationMember.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/InviteOrganizationMember.tsx
new file mode 100644
index 0000000000..da39109056
--- /dev/null
+++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/InviteOrganizationMember.tsx
@@ -0,0 +1,121 @@
+"use client";
+
+import { inviteOrganizationMemberAction } from "@/app/(app)/(onboarding)/organizations/actions";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useRouter } from "next/navigation";
+import { FormProvider, useForm } from "react-hook-form";
+import { toast } from "react-hot-toast";
+import { z } from "zod";
+import { TOrganization } from "@formbricks/types/organizations";
+import { Button } from "@formbricks/ui/Button";
+import { FormControl, FormError, FormField, FormItem, FormLabel } from "@formbricks/ui/Form";
+import { Input } from "@formbricks/ui/Input";
+
+interface InviteOrganizationMemberProps {
+ organization: TOrganization;
+ environmentId: string;
+}
+
+const ZInviteOrganizationMemberDetails = z.object({
+ email: z.string().email(),
+ inviteMessage: z.string().trim().min(1),
+});
+type TInviteOrganizationMemberDetails = z.infer;
+
+export const InviteOrganizationMember = ({ organization, environmentId }: InviteOrganizationMemberProps) => {
+ const router = useRouter();
+
+ const form = useForm({
+ defaultValues: {
+ email: "",
+ inviteMessage: "I'm looking into Formbricks to run targeted surveys. Can you help me set it up? ๐",
+ },
+ resolver: zodResolver(ZInviteOrganizationMemberDetails),
+ });
+ const { isSubmitting } = form.formState;
+
+ const handleInvite = async (data: TInviteOrganizationMemberDetails) => {
+ try {
+ await inviteOrganizationMemberAction(organization.id, data.email, "developer", data.inviteMessage);
+ toast.success("Invite sent successful");
+ await finishOnboarding();
+ } catch (error) {
+ toast.error("An unexpected error occurred");
+ }
+ };
+
+ const finishOnboarding = async () => {
+ router.push(`/environments/${environmentId}/surveys`);
+ };
+
+ return (
+
+ );
+};
diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.tsx
new file mode 100644
index 0000000000..7002813151
--- /dev/null
+++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.tsx
@@ -0,0 +1,161 @@
+"use client";
+
+import "prismjs/themes/prism.css";
+import { useState } from "react";
+import toast from "react-hot-toast";
+import { TProductConfigChannel } from "@formbricks/types/product";
+import { Button } from "@formbricks/ui/Button";
+import { CodeBlock } from "@formbricks/ui/CodeBlock";
+import { TabBar } from "@formbricks/ui/TabBar";
+import { Html5Icon, NpmIcon } from "@formbricks/ui/icons";
+
+const tabs = [
+ { id: "html", label: "HTML", icon: },
+ { id: "npm", label: "NPM", icon: },
+];
+
+interface OnboardingSetupInstructionsProps {
+ environmentId: string;
+ webAppUrl: string;
+ channel: TProductConfigChannel;
+ widgetSetupCompleted: boolean;
+}
+
+export const OnboardingSetupInstructions = ({
+ environmentId,
+ webAppUrl,
+ channel,
+ widgetSetupCompleted,
+}: OnboardingSetupInstructionsProps) => {
+ const [activeTab, setActiveTab] = useState(tabs[0].id);
+ const htmlSnippetForAppSurveys = `
+
+
+ `;
+
+ const htmlSnippetForWebsiteSurveys = `
+
+
+ `;
+
+ const npmSnippetForAppSurveys = `
+ import formbricks from "@formbricks/js/app";
+
+ if (typeof window !== "undefined") {
+ formbricks.init({
+ environmentId: "${environmentId}",
+ apiHost: "${webAppUrl}",
+ userId: "testUser",
+ });
+ }
+
+ function App() {
+ // your own app
+ }
+
+ export default App;
+ `;
+
+ const npmSnippetForWebsiteSurveys = `
+ // other imports
+ import formbricks from "@formbricks/js/website";
+
+ if (typeof window !== "undefined") {
+ formbricks.init({
+ environmentId: "${environmentId}",
+ apiHost: "${webAppUrl}",
+ });
+ }
+
+ function App() {
+ // your own app
+ }
+
+ export default App;
+
+ `;
+
+ return (
+
+
+
+
+
+ {activeTab === "npm" ? (
+
+
+ npm install @formbricks/js
+
+
or
+
+ yarn add @formbricks/js
+
+
+ Import Formbricks and initialize the widget in your Component (e.g. App.tsx):
+
+
+ {channel === "app" ? npmSnippetForAppSurveys : npmSnippetForWebsiteSurveys}
+
+
+
+ ) : activeTab === "html" ? (
+
+
+ Insert this code into the <head> tag of your website:
+
+
+
+ {channel === "app" ? htmlSnippetForAppSurveys : htmlSnippetForWebsiteSurveys}
+
+
+
+
+
+
+
+
+ ) : null}
+
+
+ );
+};
diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/invite/page.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/invite/page.tsx
new file mode 100644
index 0000000000..4b623488f5
--- /dev/null
+++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/invite/page.tsx
@@ -0,0 +1,53 @@
+import { InviteOrganizationMember } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/InviteOrganizationMember";
+import { XIcon } from "lucide-react";
+import { getServerSession } from "next-auth";
+import { notFound, redirect } from "next/navigation";
+import { authOptions } from "@formbricks/lib/authOptions";
+import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
+import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
+import { Button } from "@formbricks/ui/Button";
+import { Header } from "@formbricks/ui/Header";
+
+interface InvitePageProps {
+ params: {
+ environmentId: string;
+ };
+}
+
+const Page = async ({ params }: InvitePageProps) => {
+ const session = await getServerSession(authOptions);
+ if (!session || !session.user) {
+ return redirect(`/auth/login`);
+ }
+ const organization = await getOrganizationByEnvironmentId(params.environmentId);
+ if (!organization) {
+ throw new Error("Organization not Found");
+ }
+
+ const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
+ if (!membership || (membership.role !== "owner" && membership.role !== "admin")) {
+ return notFound();
+ }
+
+ return (
+
+ );
+};
+
+export default Page;
diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/layout.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/layout.tsx
new file mode 100644
index 0000000000..8590380654
--- /dev/null
+++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/layout.tsx
@@ -0,0 +1,21 @@
+import { getServerSession } from "next-auth";
+import { redirect } from "next/navigation";
+import { authOptions } from "@formbricks/lib/authOptions";
+import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
+import { AuthorizationError } from "@formbricks/types/errors";
+
+const OnboardingLayout = async ({ children, params }) => {
+ const session = await getServerSession(authOptions);
+ if (!session || !session.user) {
+ return redirect(`/auth/login`);
+ }
+
+ const isAuthorized = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
+ if (!isAuthorized) {
+ throw AuthorizationError;
+ }
+
+ return {children}
;
+};
+
+export default OnboardingLayout;
diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/page.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/page.tsx
new file mode 100644
index 0000000000..158cc7af9a
--- /dev/null
+++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/page.tsx
@@ -0,0 +1,65 @@
+import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
+import { getCustomHeadline } from "@/app/(app)/(onboarding)/lib/utils";
+import { XIcon } from "lucide-react";
+import { notFound } from "next/navigation";
+import { WEBAPP_URL } from "@formbricks/lib/constants";
+import { getEnvironment } from "@formbricks/lib/environment/service";
+import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
+import { Button } from "@formbricks/ui/Button";
+import { Header } from "@formbricks/ui/Header";
+
+interface ConnectPageProps {
+ params: {
+ environmentId: string;
+ };
+}
+
+const Page = async ({ params }: ConnectPageProps) => {
+ const environment = await getEnvironment(params.environmentId);
+
+ if (!environment) {
+ throw new Error("Environment not found");
+ }
+
+ const product = await getProductByEnvironmentId(environment.id);
+ if (!product) {
+ throw new Error("Product not found");
+ }
+
+ const channel = product.config.channel;
+ const industry = product.config.industry;
+
+ if (!channel || !industry) {
+ return notFound();
+ }
+ const customHeadline = getCustomHeadline(channel, industry);
+
+ return (
+
+ );
+};
+
+export default Page;
diff --git a/apps/web/app/(app)/(onboarding)/lib/utils.ts b/apps/web/app/(app)/(onboarding)/lib/utils.ts
new file mode 100644
index 0000000000..e82a587c42
--- /dev/null
+++ b/apps/web/app/(app)/(onboarding)/lib/utils.ts
@@ -0,0 +1,11 @@
+import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
+
+export const getCustomHeadline = (channel: TProductConfigChannel, industry: TProductConfigIndustry) => {
+ const combinations = {
+ "website+eCommerce": "web shop",
+ "website+saas": "landing page",
+ "app+eCommerce": "shopping app",
+ "app+saas": "SaaS app",
+ };
+ return combinations[`${channel}+${industry}`] || "app";
+};
diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.tsx
new file mode 100644
index 0000000000..b0d637532b
--- /dev/null
+++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.tsx
@@ -0,0 +1,31 @@
+import { getServerSession } from "next-auth";
+import { notFound, redirect } from "next/navigation";
+import { authOptions } from "@formbricks/lib/authOptions";
+import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
+import { canUserAccessOrganization } from "@formbricks/lib/organization/auth";
+import { AuthorizationError } from "@formbricks/types/errors";
+import { ToasterClient } from "@formbricks/ui/ToasterClient";
+
+const ProductOnboardingLayout = async ({ children, params }) => {
+ const session = await getServerSession(authOptions);
+ if (!session || !session.user) {
+ return redirect(`/auth/login`);
+ }
+
+ const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
+ if (!isAuthorized) {
+ throw AuthorizationError;
+ }
+
+ const membership = await getMembershipByUserIdOrganizationId(session.user.id, params.organizationId);
+ if (!membership || membership.role === "viewer") return notFound();
+
+ return (
+
+
+ {children}
+
+ );
+};
+
+export default ProductOnboardingLayout;
diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/channel/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/channel/page.tsx
new file mode 100644
index 0000000000..01d79c1eea
--- /dev/null
+++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/channel/page.tsx
@@ -0,0 +1,60 @@
+import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
+import { CircleUserRoundIcon, EarthIcon, SendHorizonalIcon, XIcon } from "lucide-react";
+import { getProducts } from "@formbricks/lib/product/service";
+import { Button } from "@formbricks/ui/Button";
+import { Header } from "@formbricks/ui/Header";
+
+interface ChannelPageProps {
+ params: {
+ organizationId: string;
+ };
+}
+
+const Page = async ({ params }: ChannelPageProps) => {
+ const channelOptions = [
+ {
+ title: "Public website",
+ description: "Display surveys on public websites, well timed and targeted.",
+ icon: EarthIcon,
+ iconText: "Built for scale",
+ href: `/organizations/${params.organizationId}/products/new/industry?channel=website`,
+ },
+ {
+ title: "App with sign up",
+ description: "Run highly targeted surveys with any user cohort.",
+ icon: CircleUserRoundIcon,
+ iconText: "Enrich user profiles",
+ href: `/organizations/${params.organizationId}/products/new/industry?channel=app`,
+ },
+ {
+ channel: "link",
+ title: "Anywhere online",
+ description: "Create link and email surveys, reach your people anywhere.",
+ icon: SendHorizonalIcon,
+ iconText: "100% custom branding",
+ href: `/organizations/${params.organizationId}/products/new/industry?channel=link`,
+ },
+ ];
+
+ const products = await getProducts(params.organizationId);
+
+ return (
+
+
+
+ {products.length >= 1 && (
+
+ )}
+
+ );
+};
+
+export default Page;
diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/industry/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/industry/page.tsx
new file mode 100644
index 0000000000..8ae0324c92
--- /dev/null
+++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/industry/page.tsx
@@ -0,0 +1,69 @@
+import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
+import { HeartIcon, MonitorIcon, ShoppingCart, XIcon } from "lucide-react";
+import { notFound } from "next/navigation";
+import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
+import { getProducts } from "@formbricks/lib/product/service";
+import { TProductConfigChannel } from "@formbricks/types/product";
+import { Button } from "@formbricks/ui/Button";
+import { Header } from "@formbricks/ui/Header";
+
+interface IndustryPageProps {
+ params: {
+ organizationId: string;
+ };
+ searchParams: {
+ channel?: TProductConfigChannel;
+ };
+}
+
+const Page = async ({ params, searchParams }: IndustryPageProps) => {
+ const channel = searchParams.channel;
+ if (!channel) {
+ return notFound();
+ }
+
+ const products = await getProducts(params.organizationId);
+
+ const industryOptions = [
+ {
+ title: "E-Commerce",
+ description: "Implement proven best practices to understand why people buy.",
+ icon: ShoppingCart,
+ iconText: "B2B and B2C",
+ href: `/organizations/${params.organizationId}/products/new/settings?channel=${channel}&industry=eCommerce`,
+ },
+ {
+ title: "SaaS",
+ description: "Gather contextualized feedback to improve product-market fit.",
+ icon: MonitorIcon,
+ iconText: "Proven methods",
+ href: `/organizations/${params.organizationId}/products/new/settings?channel=${channel}&industry=saas`,
+ },
+ {
+ title: "Other",
+ description: "Universal Formricks experience with features for every industry.",
+ icon: HeartIcon,
+ iconText: "Customer insights",
+ href: IS_FORMBRICKS_CLOUD
+ ? `/organizations/${params.organizationId}/products/new/survey?channel=${channel}&industry=other`
+ : `/organizations/${params.organizationId}/products/new/settings?channel=${channel}&industry=other`,
+ },
+ ];
+
+ return (
+
+
+
+ {products.length >= 1 && (
+
+ )}
+
+ );
+};
+
+export default Page;
diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings.tsx
new file mode 100644
index 0000000000..9ddfe506c8
--- /dev/null
+++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings.tsx
@@ -0,0 +1,165 @@
+"use client";
+
+import { getCustomHeadline } from "@/app/(app)/(onboarding)/lib/utils";
+import { createProductAction } from "@/app/(app)/environments/[environmentId]/actions";
+import { zodResolver } from "@hookform/resolvers/zod";
+import Image from "next/image";
+import { useRouter } from "next/navigation";
+import { useForm } from "react-hook-form";
+import { toast } from "react-hot-toast";
+import { PREVIEW_SURVEY } from "@formbricks/lib/styling/constants";
+import {
+ TProductConfigChannel,
+ TProductConfigIndustry,
+ TProductUpdateInput,
+ ZProductUpdateInput,
+} from "@formbricks/types/product";
+import { Button } from "@formbricks/ui/Button";
+import { ColorPicker } from "@formbricks/ui/ColorPicker";
+import {
+ FormControl,
+ FormDescription,
+ FormError,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormProvider,
+} from "@formbricks/ui/Form";
+import { Input } from "@formbricks/ui/Input";
+import { SurveyInline } from "@formbricks/ui/Survey";
+
+interface ProductSettingsProps {
+ organizationId: string;
+ channel: TProductConfigChannel;
+ industry: TProductConfigIndustry;
+ defaultBrandColor: string;
+}
+
+export const ProductSettings = ({
+ organizationId,
+ channel,
+ industry,
+ defaultBrandColor,
+}: ProductSettingsProps) => {
+ const router = useRouter();
+
+ const addProduct = async (data: TProductUpdateInput) => {
+ try {
+ const product = await createProductAction(organizationId, {
+ ...data,
+ config: { channel, industry },
+ });
+ // get production environment
+ const productionEnvironment = product.environments.find(
+ (environment) => environment.type === "production"
+ );
+ if (channel !== "link") {
+ router.push(`/environments/${productionEnvironment?.id}/connect`);
+ } else {
+ router.push(`/environments/${productionEnvironment?.id}/surveys`);
+ }
+ } catch (error) {
+ toast.error("Product creation failed");
+ console.error(error);
+ }
+ };
+
+ const form = useForm({
+ defaultValues: {
+ name: "",
+ styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
+ },
+ resolver: zodResolver(ZProductUpdateInput),
+ });
+ const logoUrl = form.watch("logo.url");
+ const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
+ const { isSubmitting } = form.formState;
+
+ return (
+
+
+
+
+
+
+
+
+ {logoUrl && (
+
+ )}
+
Preview
+
+ file.name}
+ autoFocus={false}
+ />
+
+
+
+ );
+};
diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/page.tsx
new file mode 100644
index 0000000000..169e193d0f
--- /dev/null
+++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/page.tsx
@@ -0,0 +1,60 @@
+import { getCustomHeadline } from "@/app/(app)/(onboarding)/lib/utils";
+import { ProductSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings";
+import { XIcon } from "lucide-react";
+import { notFound } from "next/navigation";
+import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
+import { getProducts } from "@formbricks/lib/product/service";
+import { startsWithVowel } from "@formbricks/lib/utils/strings";
+import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
+import { Button } from "@formbricks/ui/Button";
+import { Header } from "@formbricks/ui/Header";
+
+interface ProductSettingsPageProps {
+ params: {
+ organizationId: string;
+ };
+ searchParams: {
+ channel?: TProductConfigChannel;
+ industry?: TProductConfigIndustry;
+ };
+}
+
+const Page = async ({ params, searchParams }: ProductSettingsPageProps) => {
+ const channel = searchParams.channel;
+ const industry = searchParams.industry;
+ if (!channel || !industry) return notFound();
+ const customHeadline = getCustomHeadline(channel, industry);
+ const products = await getProducts(params.organizationId);
+
+ return (
+
+ {channel === "link" ? (
+
+ ) : (
+
+ )}
+
+ {products.length >= 1 && (
+
+ )}
+
+ );
+};
+
+export default Page;
diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/survey/components/OnboardingSurvey.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/survey/components/OnboardingSurvey.tsx
new file mode 100644
index 0000000000..962e9a68fe
--- /dev/null
+++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/survey/components/OnboardingSurvey.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import OnboardingSurveyBg from "@/images/onboarding-survey-bg.jpg";
+import Image from "next/image";
+import { useRouter } from "next/navigation";
+import React, { useEffect, useState } from "react";
+import { TProductConfigChannel } from "@formbricks/types/product";
+
+interface OnboardingSurveyProps {
+ organizationId: string;
+ channel: TProductConfigChannel;
+}
+
+export const OnboardingSurvey = ({ organizationId, channel }: OnboardingSurveyProps) => {
+ const [isIFrameVisible, setIsIFrameVisible] = useState(false);
+ const [fadeout, setFadeout] = useState(false);
+ const router = useRouter();
+
+ const handleMessageEvent = (event: MessageEvent) => {
+ if (event.data === "formbricksSurveyCompleted") {
+ setFadeout(true); // Start fade-out
+ setTimeout(() => {
+ router.push(
+ `/organizations/${organizationId}/products/new/settings?channel=${channel}&industry=other`
+ );
+ }, 800); // Delay the navigation until fade-out completes
+ }
+ };
+
+ useEffect(() => {
+ if (isIFrameVisible) {
+ window.addEventListener("message", handleMessageEvent, false);
+ return () => {
+ window.removeEventListener("message", handleMessageEvent, false);
+ };
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isIFrameVisible]);
+
+ return (
+
+ );
+};
diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/survey/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/survey/page.tsx
new file mode 100644
index 0000000000..a265d3166e
--- /dev/null
+++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/survey/page.tsx
@@ -0,0 +1,23 @@
+import { OnboardingSurvey } from "@/app/(app)/(onboarding)/organizations/[organizationId]/products/new/survey/components/OnboardingSurvey";
+import { notFound } from "next/navigation";
+import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
+
+interface OnboardingSurveyPageProps {
+ params: {
+ organizationId: string;
+ };
+ searchParams: {
+ channel?: TProductConfigChannel;
+ industry?: TProductConfigIndustry;
+ };
+}
+
+const Page = async ({ params, searchParams }: OnboardingSurveyPageProps) => {
+ const channel = searchParams.channel;
+ const industry = searchParams.industry;
+ if (!channel || !industry) return notFound();
+
+ return ;
+};
+
+export default Page;
diff --git a/apps/web/app/(app)/(onboarding)/organizations/actions.ts b/apps/web/app/(app)/(onboarding)/organizations/actions.ts
new file mode 100644
index 0000000000..c1cbe7ae26
--- /dev/null
+++ b/apps/web/app/(app)/(onboarding)/organizations/actions.ts
@@ -0,0 +1,61 @@
+"use server";
+
+import { getServerSession } from "next-auth";
+import { sendInviteMemberEmail } from "@formbricks/email";
+import { hasOrganizationAuthority } from "@formbricks/lib/auth";
+import { authOptions } from "@formbricks/lib/authOptions";
+import { INVITE_DISABLED } from "@formbricks/lib/constants";
+import { inviteUser } from "@formbricks/lib/invite/service";
+import { verifyUserRoleAccess } from "@formbricks/lib/organization/auth";
+import { AuthenticationError } from "@formbricks/types/errors";
+import { TMembershipRole } from "@formbricks/types/memberships";
+
+export const inviteOrganizationMemberAction = async (
+ organizationId: string,
+ email: string,
+ role: TMembershipRole,
+ inviteMessage: string
+) => {
+ const session = await getServerSession(authOptions);
+
+ if (!session) {
+ throw new AuthenticationError("Not authenticated");
+ }
+
+ const isUserAuthorized = await hasOrganizationAuthority(session.user.id, organizationId);
+
+ if (INVITE_DISABLED) {
+ throw new AuthenticationError("Invite disabled");
+ }
+
+ if (!isUserAuthorized) {
+ throw new AuthenticationError("Not authorized");
+ }
+
+ const { hasCreateOrUpdateMembersAccess } = await verifyUserRoleAccess(organizationId, session.user.id);
+ if (!hasCreateOrUpdateMembersAccess) {
+ throw new AuthenticationError("Not authorized");
+ }
+
+ const invite = await inviteUser({
+ organizationId,
+ invitee: {
+ email,
+ name: "",
+ role,
+ },
+ });
+
+ if (invite) {
+ await sendInviteMemberEmail(
+ invite.id,
+ email,
+ session.user.name ?? "",
+ "",
+ true, // is onboarding invite
+ inviteMessage
+ );
+ }
+
+ return invite;
+};
diff --git a/apps/web/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer.tsx b/apps/web/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer.tsx
new file mode 100644
index 0000000000..542bffd4b3
--- /dev/null
+++ b/apps/web/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer.tsx
@@ -0,0 +1,41 @@
+import { LucideProps } from "lucide-react";
+import Link from "next/link";
+import { ForwardRefExoticComponent, RefAttributes } from "react";
+import { OptionCard } from "@formbricks/ui/OptionCard";
+
+interface OnboardingOptionsContainerProps {
+ options: {
+ title: string;
+ description: string;
+ icon: ForwardRefExoticComponent & RefAttributes>;
+ iconText: string;
+ href: string;
+ }[];
+}
+
+export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => {
+ return (
+
+ {options.map((option, index) => {
+ const Icon = option.icon;
+ return (
+
+
+
+
+
+ {option.iconText}
+
+
+
+
+ );
+ })}
+
+ );
+};
diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/actions.ts b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/actions.ts
deleted file mode 100644
index 4ae89d66a8..0000000000
--- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/actions.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-"use server";
-
-import { getServerSession } from "next-auth";
-import { authOptions } from "@formbricks/lib/authOptions";
-import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
-import { createSurvey } from "@formbricks/lib/survey/service";
-import { AuthorizationError } from "@formbricks/types/errors";
-import { TSurveyInput } from "@formbricks/types/surveys";
-
-export const createSurveyAction = async (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);
-};
diff --git a/apps/web/app/(app)/environments/[environmentId]/actions.ts b/apps/web/app/(app)/environments/[environmentId]/actions.ts
index 85ce61beb9..41197e7a3e 100644
--- a/apps/web/app/(app)/environments/[environmentId]/actions.ts
+++ b/apps/web/app/(app)/environments/[environmentId]/actions.ts
@@ -5,18 +5,13 @@ import { getServerSession } from "next-auth";
import { getIsMultiOrgEnabled } from "@formbricks/ee/lib/service";
import { authOptions } from "@formbricks/lib/authOptions";
import { SHORT_URL_BASE, WEBAPP_URL } from "@formbricks/lib/constants";
-import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
-import { createMembership } from "@formbricks/lib/membership/service";
-import { createOrganization, getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
+import { createMembership, getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
+import { createOrganization } from "@formbricks/lib/organization/service";
import { createProduct } from "@formbricks/lib/product/service";
import { createShortUrl } from "@formbricks/lib/shortUrl/service";
import { updateUser } from "@formbricks/lib/user/service";
-import {
- AuthenticationError,
- AuthorizationError,
- OperationNotAllowedError,
- ResourceNotFoundError,
-} from "@formbricks/types/errors";
+import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
+import { TProduct, TProductUpdateInput } from "@formbricks/types/product";
import { TUserNotificationSettings } from "@formbricks/types/user";
export const createShortUrlAction = async (url: string) => {
@@ -76,19 +71,19 @@ export const createOrganizationAction = async (organizationName: string): Promis
return newOrganization;
};
-export const createProductAction = async (environmentId: string, productName: string) => {
+export const createProductAction = async (
+ organizationId: string,
+ productInput: TProductUpdateInput
+): Promise => {
const session = await getServerSession(authOptions);
- if (!session) throw new AuthorizationError("Not authorized");
+ if (!session) throw new AuthorizationError("Not authenticated");
- const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
- if (!isAuthorized) throw new AuthorizationError("Not authorized");
+ const membership = await getMembershipByUserIdOrganizationId(session.user.id, organizationId);
+ if (!membership || membership.role === "viewer") {
+ throw new AuthorizationError("Product creation not allowed");
+ }
- const organization = await getOrganizationByEnvironmentId(environmentId);
- if (!organization) throw new ResourceNotFoundError("Organization from environment", environmentId);
-
- const product = await createProduct(organization.id, {
- name: productName,
- });
+ const product = await createProduct(organizationId, productInput);
const updatedNotificationSettings = {
...session.user.notificationSettings,
alert: {
@@ -104,9 +99,5 @@ export const createProductAction = async (environmentId: string, productName: st
notificationSettings: updatedNotificationSettings,
});
- // get production environment
- const productionEnvironment = product.environments.find((environment) => environment.type === "production");
- if (!productionEnvironment) throw new ResourceNotFoundError("Production environment", environmentId);
-
- return productionEnvironment;
+ return product;
};
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/AddProductModal.tsx b/apps/web/app/(app)/environments/[environmentId]/components/AddProductModal.tsx
deleted file mode 100644
index 21dae49b4d..0000000000
--- a/apps/web/app/(app)/environments/[environmentId]/components/AddProductModal.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-"use client";
-
-import { createProductAction } from "@/app/(app)/environments/[environmentId]/actions";
-import { PlusCircleIcon } from "lucide-react";
-import { useRouter } from "next/navigation";
-import { useState } from "react";
-import { useForm } from "react-hook-form";
-import toast from "react-hot-toast";
-import { Button } from "@formbricks/ui/Button";
-import { Input } from "@formbricks/ui/Input";
-import { Label } from "@formbricks/ui/Label";
-import { Modal } from "@formbricks/ui/Modal";
-
-interface AddProductModalProps {
- environmentId: string;
- open: boolean;
- setOpen: (v: boolean) => void;
-}
-
-export const AddProductModal = ({ environmentId, open, setOpen }: AddProductModalProps) => {
- const router = useRouter();
- const [loading, setLoading] = useState(false);
- const [productName, setProductName] = useState("");
- const isProductNameValid = productName.trim() !== "";
- const { register, handleSubmit } = useForm();
-
- const submitProduct = async (data: { name: string }) => {
- data.name = data.name.trim();
- if (!isProductNameValid) return;
-
- try {
- setLoading(true);
- const newEnv = await createProductAction(environmentId, data.name);
-
- toast.success("Product created successfully!");
- router.push(`/environments/${newEnv.id}/`);
- setOpen(false);
- } catch (error) {
- console.error(error);
- toast.error(`Error: Unable to save product information`);
- } finally {
- setLoading(false);
- }
- };
-
- return (
-
-
-
-
-
-
-
-
Add Product
-
Create a new product for your organization.
-
-
-
-
-
-
-
- );
-};
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx
index df7709c154..aa9eefe7b6 100644
--- a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx
@@ -49,7 +49,6 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@formbricks/ui/DropdownMenu";
-import { AddProductModal } from "./AddProductModal";
interface NavigationProps {
environment: TEnvironment;
@@ -77,7 +76,6 @@ export const MainNavigation = ({
const [currentOrganizationName, setCurrentOrganizationName] = useState("");
const [currentOrganizationId, setCurrentOrganizationId] = useState("");
- const [showAddProductModal, setShowAddProductModal] = useState(false);
const [showCreateOrganizationModal, setShowCreateOrganizationModal] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(true);
const [isTextVisible, setIsTextVisible] = useState(true);
@@ -127,6 +125,10 @@ export const MainNavigation = ({
router.push(`/organizations/${organizationId}/`);
};
+ const handleAddProduct = (organizationId: string) => {
+ router.push(`/organizations/${organizationId}/products/new/channel`);
+ };
+
const mainNavigation = useMemo(
() => [
{
@@ -328,7 +330,7 @@ export const MainNavigation = ({
{!isViewer && (
- setShowAddProductModal(true)} className="rounded-lg">
+ handleAddProduct(organization.id)} className="rounded-lg">
Add product
@@ -464,11 +466,6 @@ export const MainNavigation = ({
open={showCreateOrganizationModal}
setOpen={(val) => setShowCreateOrganizationModal(val)}
/>
- setShowAddProductModal(val)}
- environmentId={environment.id}
- />
>
);
};
diff --git a/apps/web/app/(app)/environments/[environmentId]/layout.tsx b/apps/web/app/(app)/environments/[environmentId]/layout.tsx
index c19fd1ab05..fe9dd44163 100644
--- a/apps/web/app/(app)/environments/[environmentId]/layout.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/layout.tsx
@@ -1,10 +1,12 @@
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getServerSession } from "next-auth";
-import { redirect } from "next/navigation";
+import { notFound, redirect } from "next/navigation";
import { authOptions } from "@formbricks/lib/authOptions";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
+import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
+import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { AuthorizationError } from "@formbricks/types/errors";
import { ToasterClient } from "@formbricks/ui/ToasterClient";
import { FormbricksClient } from "../../components/FormbricksClient";
@@ -24,6 +26,13 @@ const EnvLayout = async ({ children, params }) => {
if (!organization) {
throw new Error("Organization not found");
}
+ const product = await getProductByEnvironmentId(params.environmentId);
+ if (!product) {
+ throw new Error("Product not found");
+ }
+
+ const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
+ if (!membership) return notFound();
return (
<>
diff --git a/apps/web/app/(app)/environments/[environmentId]/product/general/actions.ts b/apps/web/app/(app)/environments/[environmentId]/product/general/actions.ts
index c3363f54d4..4e3199e8f2 100644
--- a/apps/web/app/(app)/environments/[environmentId]/product/general/actions.ts
+++ b/apps/web/app/(app)/environments/[environmentId]/product/general/actions.ts
@@ -53,7 +53,7 @@ export const updateProductAction = async (
}
if (membership.role === "developer") {
- if (!!data.name || !!data.brandColor || !!data.organizationId || !!data.environments) {
+ if (!!data.name || !!data.organizationId || !!data.environments) {
throw new AuthorizationError("Not authorized");
}
}
@@ -62,13 +62,13 @@ export const updateProductAction = async (
return updatedProduct;
};
-export const deleteProductAction = async (environmentId: string, userId: string, productId: string) => {
+export const deleteProductAction = async (environmentId: string, productId: string) => {
const session = await getServerSession(authOptions);
if (!session?.user) {
throw new AuthenticationError("Not authenticated");
}
-
+ const userId = session.user.id;
// get the environment from service and check if the user is allowed to update the product
let environment: TEnvironment | null = null;
diff --git a/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProduct.tsx b/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProduct.tsx
index 0bd1ec0b16..96da25fb0a 100644
--- a/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProduct.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProduct.tsx
@@ -37,7 +37,6 @@ export const DeleteProduct = async ({ environmentId, product }: DeleteProductPro
isUserAdminOrOwner={isUserAdminOrOwner}
product={product}
environmentId={environmentId}
- userId={session?.user.id ?? ""}
/>
);
};
diff --git a/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProductRender.tsx b/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProductRender.tsx
index 4e0dbcbc52..d3b8c543cf 100644
--- a/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProductRender.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProductRender.tsx
@@ -14,7 +14,6 @@ type DeleteProductRenderProps = {
isDeleteDisabled: boolean;
isUserAdminOrOwner: boolean;
product: TProduct;
- userId: string;
};
export const DeleteProductRender = ({
@@ -22,7 +21,6 @@ export const DeleteProductRender = ({
isDeleteDisabled,
isUserAdminOrOwner,
product,
- userId,
}: DeleteProductRenderProps) => {
const router = useRouter();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
@@ -31,7 +29,7 @@ export const DeleteProductRender = ({
const handleDeleteProduct = async () => {
try {
setIsDeleting(true);
- const deletedProduct = await deleteProductAction(environmentId, userId, product.id);
+ const deletedProduct = await deleteProductAction(environmentId, product.id);
if (!!deletedProduct?.id) {
toast.success("Product deleted successfully.");
router.push("/");
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/actions.ts
deleted file mode 100644
index 4ae89d66a8..0000000000
--- a/apps/web/app/(app)/environments/[environmentId]/surveys/actions.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-"use server";
-
-import { getServerSession } from "next-auth";
-import { authOptions } from "@formbricks/lib/authOptions";
-import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
-import { createSurvey } from "@formbricks/lib/survey/service";
-import { AuthorizationError } from "@formbricks/types/errors";
-import { TSurveyInput } from "@formbricks/types/surveys";
-
-export const createSurveyAction = async (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);
-};
diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx
index 5cf990c15f..3e8d96a71e 100644
--- a/apps/web/app/(app)/layout.tsx
+++ b/apps/web/app/(app)/layout.tsx
@@ -4,6 +4,7 @@ import { Suspense } from "react";
import { authOptions } from "@formbricks/lib/authOptions";
import { NoMobileOverlay } from "@formbricks/ui/NoMobileOverlay";
import { PHProvider, PostHogPageview } from "@formbricks/ui/PostHogClient";
+import { ToasterClient } from "@formbricks/ui/ToasterClient";
const AppLayout = async ({ children }) => {
const session = await getServerSession(authOptions);
@@ -17,6 +18,7 @@ const AppLayout = async ({ children }) => {
<>
{session ? : null}
+
{children}
>
diff --git a/apps/web/app/(app)/onboarding/actions.ts b/apps/web/app/(app)/onboarding/actions.ts
deleted file mode 100644
index f009f4e7a4..0000000000
--- a/apps/web/app/(app)/onboarding/actions.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-"use server";
-
-import { getServerSession } from "next-auth";
-import { sendInviteMemberEmail } from "@formbricks/email";
-import { hasOrganizationAuthority } from "@formbricks/lib/auth";
-import { authOptions } from "@formbricks/lib/authOptions";
-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 { verifyUserRoleAccess } from "@formbricks/lib/organization/auth";
-import { canUserAccessProduct } from "@formbricks/lib/product/auth";
-import { getProduct, updateProduct } from "@formbricks/lib/product/service";
-import { createSurvey } from "@formbricks/lib/survey/service";
-import { updateUser } from "@formbricks/lib/user/service";
-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 inviteOrganizationMemberAction = async (
- organizationId: string,
- email: string,
- role: TMembershipRole,
- inviteMessage: string
-) => {
- const session = await getServerSession(authOptions);
-
- if (!session) {
- throw new AuthenticationError("Not authenticated");
- }
-
- const isUserAuthorized = await hasOrganizationAuthority(session.user.id, organizationId);
-
- if (INVITE_DISABLED) {
- throw new AuthenticationError("Invite disabled");
- }
-
- if (!isUserAuthorized) {
- throw new AuthenticationError("Not authorized");
- }
-
- const { hasCreateOrUpdateMembersAccess } = await verifyUserRoleAccess(organizationId, session.user.id);
- if (!hasCreateOrUpdateMembersAccess) {
- throw new AuthenticationError("Not authorized");
- }
-
- const invite = await inviteUser({
- organizationId,
- invitee: {
- email,
- name: "",
- role,
- },
- });
-
- if (invite) {
- await sendInviteMemberEmail(
- invite.id,
- email,
- session.user.name ?? "",
- "",
- true, // is onboarding invite
- 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 const createSurveyAction = async (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 const fetchEnvironment = async (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 const updateUserAction = async (updatedUser: TUserUpdateInput) => {
- const session = await getServerSession(authOptions);
- if (!session) throw new AuthorizationError("Not authorized");
-
- return await updateUser(session.user.id, updatedUser);
-};
-
-export const updateProductAction = async (
- productId: string,
- updatedProduct: Partial
-) => {
- const session = await getServerSession(authOptions);
- if (!session) throw new AuthorizationError("Not authorized");
-
- const isAuthorized = await canUserAccessProduct(session.user.id, productId);
- if (!isAuthorized) throw new AuthorizationError("Not authorized");
-
- const product = await getProduct(productId);
-
- const { hasCreateOrUpdateAccess } = await verifyUserRoleAccess(product!.organizationId, session.user.id);
- if (!hasCreateOrUpdateAccess) throw new AuthorizationError("Not authorized");
-
- return await updateProduct(productId, updatedProduct);
-};
diff --git a/apps/web/app/(app)/onboarding/components/OnboardingTitle.tsx b/apps/web/app/(app)/onboarding/components/OnboardingTitle.tsx
deleted file mode 100644
index 29dcf6ef16..0000000000
--- a/apps/web/app/(app)/onboarding/components/OnboardingTitle.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-// Filename: IntroSection.tsx
-import React from "react";
-
-type OnboardingTitleProps = {
- title: string;
- subtitle: string;
-};
-
-export const OnboardingTitle: React.FC = ({ title, subtitle }) => {
- return (
-
- );
-};
diff --git a/apps/web/app/(app)/onboarding/components/PathwaySelect.tsx b/apps/web/app/(app)/onboarding/components/PathwaySelect.tsx
deleted file mode 100644
index 3aa33993fb..0000000000
--- a/apps/web/app/(app)/onboarding/components/PathwaySelect.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-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" | "website" | null) => void;
- setCurrentStep: (currentStep: number) => void;
- isFormbricksCloud: boolean;
-}
-
-type PathwayOptionType = "link" | "website";
-
-export const 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", "website");
- setCurrentStep(2);
- localStorage.setItem("onboardingCurrentStep", "2");
- }
- setSelectedPathway(pathway);
- };
-
- return (
-
-
-
- {
- handleSelect("link");
- }}>
-
-
- {
- handleSelect("website");
- }}>
-
-
-
-
- );
-};
diff --git a/apps/web/app/(app)/onboarding/components/ProgressBar.tsx b/apps/web/app/(app)/onboarding/components/ProgressBar.tsx
deleted file mode 100644
index 6455515bbe..0000000000
--- a/apps/web/app/(app)/onboarding/components/ProgressBar.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Logo } from "@formbricks/ui/Logo";
-import { ProgressBar } from "@formbricks/ui/ProgressBar";
-
-interface OnboardingHeaderProps {
- progress: number;
-}
-export const OnboardingHeader = ({ progress }: OnboardingHeaderProps) => {
- return (
-
-
-
-
-
-
-
-
{progress}% complete
-
-
- );
-};
diff --git a/apps/web/app/(app)/onboarding/components/inapp/ConnectWithFormbricks.tsx b/apps/web/app/(app)/onboarding/components/inapp/ConnectWithFormbricks.tsx
deleted file mode 100644
index 6c91e8e39f..0000000000
--- a/apps/web/app/(app)/onboarding/components/inapp/ConnectWithFormbricks.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-"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 { usePostHog } from "posthog-js/react";
-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 goToOrganizationInvitePage = 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);
- const posthog = usePostHog();
- posthog.capture("onboarding-sdk-connected");
- return (
-
-
-
-
-
-
-
Connection successful โ
-
-
-
-
-
- );
-};
-
-const NotConnectedState = ({ environment, webAppUrl, jsPackageVersion, goToOrganizationInvitePage }) => {
- return (
-
-
-
-
- Waiting for your signal...
-
-
-
-
-
-
- );
-};
-
-interface ConnectProps {
- environment: TEnvironment;
- webAppUrl: string;
- jsPackageVersion: string;
- setCurrentStep: (currentStep: number) => void;
-}
-
-export const ConnectWithFormbricks = ({
- environment,
- webAppUrl,
- jsPackageVersion,
- setCurrentStep,
-}: ConnectProps) => {
- const router = useRouter();
- const [localEnvironment, setLocalEnvironment] = useState(environment);
-
- useVisibilityChange(environment, setLocalEnvironment);
-
- const widgetSetupCompleted = localEnvironment.websiteSetupCompleted;
-
- useEffect(() => {
- const fetchLatestEnvironmentOnFirstLoad = async () => {
- const refetchedEnvironment = await fetchEnvironment(environment.id);
- if (!refetchedEnvironment) return;
- setLocalEnvironment(refetchedEnvironment);
- };
- fetchLatestEnvironmentOnFirstLoad();
- }, [environment.id]);
-
- return widgetSetupCompleted ? (
- {
- goToProduct(router);
- }}
- />
- ) : (
- {
- setCurrentStep(5);
- localStorage.setItem("onboardingCurrentStep", "5");
- goToOrganizationInvitePage();
- }}
- />
- );
-};
diff --git a/apps/web/app/(app)/onboarding/components/inapp/InviteOrganizationMate.tsx b/apps/web/app/(app)/onboarding/components/inapp/InviteOrganizationMate.tsx
deleted file mode 100644
index 08db17d51d..0000000000
--- a/apps/web/app/(app)/onboarding/components/inapp/InviteOrganizationMate.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-"use client";
-
-import { OnboardingTitle } from "@/app/(app)/onboarding/components/OnboardingTitle";
-import { useRouter } from "next/navigation";
-import { useState } from "react";
-import { toast } from "react-hot-toast";
-import { isValidEmail } from "@formbricks/lib/utils/email";
-import { TOrganization } from "@formbricks/types/organizations";
-import { Button } from "@formbricks/ui/Button";
-import { Input } from "@formbricks/ui/Input";
-import { finishOnboardingAction, inviteOrganizationMemberAction } from "../../actions";
-
-interface InviteOrganizationMemberProps {
- organization: TOrganization;
- 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 };
-
-const InviteMessageInput = ({ value, onChange }) => {
- return (
-
- );
-};
-
-export const InviteOrganizationMember = ({
- organization,
- environmentId,
- setCurrentStep,
-}: InviteOrganizationMemberProps) => {
- 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 inviteOrganizationMemberAction(
- organization.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 (
-
-
-
-
-
handleInputChange(e, "email")}
- />
-
-
handleInputChange(e, "inviteMessage")}
- />
-
-
-
-
-
-
-
-
-
-
-
-
- );
-};
diff --git a/apps/web/app/(app)/onboarding/components/inapp/SetupInstructions.tsx b/apps/web/app/(app)/onboarding/components/inapp/SetupInstructions.tsx
deleted file mode 100644
index 56d14ccfec..0000000000
--- a/apps/web/app/(app)/onboarding/components/inapp/SetupInstructions.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-"use client";
-
-import "prismjs/themes/prism.css";
-import { useState } from "react";
-import toast from "react-hot-toast";
-import { cn } from "@formbricks/lib/cn";
-import { Button } from "@formbricks/ui/Button";
-import { CodeBlock } from "@formbricks/ui/CodeBlock";
-import { Html5Icon, NpmIcon } from "@formbricks/ui/icons";
-
-const tabs = [
- { id: "html", label: "HTML", icon: },
- { id: "npm", label: "NPM", icon: },
-];
-
-interface SetupInstructionsOnboardingProps {
- environmentId: string;
- webAppUrl: string;
- jsPackageVersion: string;
-}
-
-export const SetupInstructionsOnboarding = ({
- environmentId,
- webAppUrl,
-}: SetupInstructionsOnboardingProps) => {
- const [activeTab, setActiveId] = useState(tabs[0].id);
- const htmlSnippet = `
-
-`;
-
- return (
-
-
-
-
-
- {activeTab === "npm" ? (
-
-
- npm install @formbricks/js
-
-
or
-
- yarn add @formbricks/js
-
-
- Import Formbricks and initialize the widget in your Component (e.g. App.tsx):
-
-
{`import formbricks from "@formbricks/js/website";
-
-if (typeof window !== "undefined") {
- formbricks.init({
- environmentId: "${environmentId}",
- apiHost: "${webAppUrl}",
- });
-}`}
-
-
- ) : activeTab === "html" ? (
-
-
- Insert this code into the <head> tag of your website:
-
-
- {htmlSnippet}
-
-
-
-
-
-
- ) : null}
-
-
- );
-};
diff --git a/apps/web/app/(app)/onboarding/components/inapp/SurveyObjective.tsx b/apps/web/app/(app)/onboarding/components/inapp/SurveyObjective.tsx
deleted file mode 100644
index 97473bfdef..0000000000
--- a/apps/web/app/(app)/onboarding/components/inapp/SurveyObjective.tsx
+++ /dev/null
@@ -1,163 +0,0 @@
-"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 = ({ formbricksResponseId, user, setCurrentStep }) => {
- const objectives: Array = [
- { 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(null);
- const [isProfileUpdating, setIsProfileUpdating] = useState(false);
- const [otherValue, setOtherValue] = useState("");
-
- const fieldsetRef = useRef(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 (
-
-
-
-
-
-
-
-
-
- );
-};
diff --git a/apps/web/app/(app)/onboarding/components/inapp/SurveyRole.tsx b/apps/web/app/(app)/onboarding/components/inapp/SurveyRole.tsx
deleted file mode 100644
index 281b56eb1d..0000000000
--- a/apps/web/app/(app)/onboarding/components/inapp/SurveyRole.tsx
+++ /dev/null
@@ -1,159 +0,0 @@
-"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 = ({ setFormbricksResponseId, session, setCurrentStep }) => {
- const [selectedChoice, setSelectedChoice] = useState(null);
- const [isUpdating, setIsUpdating] = useState(false);
- const fieldsetRef = useRef(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 = [
- { 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 (
-
-
-
-
-
-
-
-
- );
-};
diff --git a/apps/web/app/(app)/onboarding/components/link/CreateFirstSurvey.tsx b/apps/web/app/(app)/onboarding/components/link/CreateFirstSurvey.tsx
deleted file mode 100644
index 9b0bdde523..0000000000
--- a/apps/web/app/(app)/onboarding/components/link/CreateFirstSurvey.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-"use client";
-
-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 { customSurvey, templates } from "@formbricks/lib/templates";
-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 const CreateFirstSurvey = ({ environmentId }: CreateFirstSurveyProps) => {
- const router = useRouter();
- const [loadingTemplate, setLoadingTemplate] = useState(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 (
-
-
-
- {filteredTemplates.map((template) => {
- const TemplateImage = templateImages[template.name];
- const cssId = `onboarding-link-template-${template.name.toLowerCase().replace(/ /g, "-")}`;
- return (
- newSurveyFromTemplate(template)}
- loading={loadingTemplate === template.name}>
-
-
- );
- })}
-
-
-
-
- );
-};
diff --git a/apps/web/app/(app)/onboarding/components/onboarding.tsx b/apps/web/app/(app)/onboarding/components/onboarding.tsx
deleted file mode 100644
index dcd71c8921..0000000000
--- a/apps/web/app/(app)/onboarding/components/onboarding.tsx
+++ /dev/null
@@ -1,190 +0,0 @@
-"use client";
-
-import jsPackageJson from "@/../../packages/js/package.json";
-import { finishOnboardingAction } from "@/app/(app)/onboarding/actions";
-import { ConnectWithFormbricks } from "@/app/(app)/onboarding/components/inapp/ConnectWithFormbricks";
-import { InviteOrganizationMember } from "@/app/(app)/onboarding/components/inapp/InviteOrganizationMate";
-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 { useRouter } from "next/navigation";
-import { useEffect, useState } from "react";
-import { TEnvironment } from "@formbricks/types/environment";
-import { TOrganization } from "@formbricks/types/organizations";
-import { TUser } from "@formbricks/types/user";
-import { PathwaySelect } from "./PathwaySelect";
-import { OnboardingHeader } from "./ProgressBar";
-
-interface OnboardingProps {
- isFormbricksCloud: boolean;
- session: Session;
- environment: TEnvironment;
- user: TUser;
- organization: TOrganization;
- webAppUrl: string;
-}
-
-export const Onboarding = ({
- isFormbricksCloud,
- session,
- environment,
- user,
- organization,
- webAppUrl,
-}: OnboardingProps) => {
- const router = useRouter();
- const [selectedPathway, setSelectedPathway] = useState(null);
- const [progress, setProgress] = useState(16);
- const [formbricksResponseId, setFormbricksResponseId] = useState();
- const [currentStep, setCurrentStep] = useState(null);
- const [iframeLoaded, setIframeLoaded] = useState(false);
- const [iframeVisible, setIframeVisible] = useState(false);
- const [fade, setFade] = useState(false);
-
- 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
- };
-
- const handleMessageEvent = (event: MessageEvent) => {
- if (event.origin !== webAppUrl) return;
-
- if (event.data === "formbricksSurveyCompleted") {
- handleSurveyCompletion();
- }
- };
-
- useEffect(() => {
- if (currentStep === 2 && selectedPathway === "link") {
- setIframeVisible(true);
- } else {
- setIframeVisible(false);
- }
- }, [currentStep, iframeLoaded, selectedPathway]);
-
- useEffect(() => {
- if (iframeVisible) {
- setFade(true);
- window.addEventListener("message", handleMessageEvent, false);
- // Cleanup function to remove the event listener
- return () => {
- window.removeEventListener("message", handleMessageEvent, false);
- };
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [iframeVisible, currentStep]); // Depend on iframeVisible and currentStep to re-evaluate when needed
-
- useEffect(() => {
- 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 (
-
- );
- case 2:
- return (
- selectedPathway !== "link" && (
-
- )
- );
- case 3:
- return (
-
- );
- case 4:
- return (
-
- );
- case 5:
- return selectedPathway === "link" ? (
-
- ) : (
-
- );
- default:
- return null;
- }
- };
-
- return (
-
-
-
-
-
-
-
- {renderOnboardingStep()}
- {iframeVisible && isFormbricksCloud && (
-
- )}
-
-
- );
-};
diff --git a/apps/web/app/(app)/onboarding/layout.tsx b/apps/web/app/(app)/onboarding/layout.tsx
deleted file mode 100644
index d5742fa1be..0000000000
--- a/apps/web/app/(app)/onboarding/layout.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
-import { getServerSession } from "next-auth";
-import { redirect } from "next/navigation";
-import { authOptions } from "@formbricks/lib/authOptions";
-import { ToasterClient } from "@formbricks/ui/ToasterClient";
-
-const EnvironmentLayout = async ({ children }) => {
- const session = await getServerSession(authOptions);
- if (!session || !session.user) {
- return redirect(`/auth/login`);
- }
-
- return (
-
- );
-};
-
-export default EnvironmentLayout;
diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx
deleted file mode 100644
index 15329f3906..0000000000
--- a/apps/web/app/(app)/onboarding/page.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-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 { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
-import { getUser } from "@formbricks/lib/user/service";
-
-const Page = async () => {
- const session = await getServerSession(authOptions);
-
- // Redirect to login if not authenticated
- if (!session) {
- return redirect("/auth/login");
- }
-
- // Redirect to home if onboarding is completed
- if (session.user.onboardingCompleted) {
- return redirect("/");
- }
-
- const userId = session.user.id;
- const environment = await getFirstEnvironmentByUserId(userId);
- const user = await getUser(userId);
- const organization = environment ? await getOrganizationByEnvironmentId(environment.id) : null;
-
- // Ensure all necessary data is available
- if (!environment || !user || !organization) {
- throw new Error("Failed to get necessary user, environment, or organization information");
- }
-
- return (
-
- );
-};
-
-export default Page;
diff --git a/apps/web/app/(app)/onboarding/utils.ts b/apps/web/app/(app)/onboarding/utils.ts
deleted file mode 100644
index 20ed0b1dfe..0000000000
--- a/apps/web/app/(app)/onboarding/utils.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-export const handleTabNavigation = (fieldsetRef, setSelectedChoice) => (event) => {
- if (event.key !== "Tab") {
- return;
- }
-
- event.preventDefault();
-
- const radioButtons = fieldsetRef.current?.querySelectorAll('input[type="radio"]');
- if (!radioButtons || radioButtons.length === 0) {
- return;
- }
-
- const focusedRadioButton = fieldsetRef.current?.querySelector(
- 'input[type="radio"]:focus'
- ) as HTMLInputElement;
-
- if (!focusedRadioButton) {
- // If no radio button is focused, then it will focus on the first one by default
- const firstRadioButton = radioButtons[0] as HTMLInputElement;
- firstRadioButton.focus();
- setSelectedChoice(firstRadioButton.value);
- return;
- }
-
- const focusedIndex = Array.from(radioButtons).indexOf(focusedRadioButton);
- const lastIndex = radioButtons.length - 1;
-
- // Calculating the next index, considering wrapping from the last to the first element
- const nextIndex = focusedIndex === lastIndex ? 0 : focusedIndex + 1;
- const nextRadioButton = radioButtons[nextIndex] as HTMLInputElement;
- nextRadioButton.focus();
- setSelectedChoice(nextRadioButton.value);
-};
diff --git a/apps/web/app/(auth)/invite/page.tsx b/apps/web/app/(auth)/invite/page.tsx
index b2680d9aab..7c18854517 100644
--- a/apps/web/app/(auth)/invite/page.tsx
+++ b/apps/web/app/(auth)/invite/page.tsx
@@ -86,7 +86,6 @@ const Page = async ({ searchParams }) => {
invite.creator.email
);
await updateUser(session.user.id, {
- onboardingCompleted: true,
notificationSettings: {
...session.user.notificationSettings,
unsubscribedOrganizationIds: Array.from(
diff --git a/apps/web/app/api/v1/(legacy)/js/sync/lib/sync.ts b/apps/web/app/api/v1/(legacy)/js/sync/lib/sync.ts
index 03d6987192..5c4540654a 100644
--- a/apps/web/app/api/v1/(legacy)/js/sync/lib/sync.ts
+++ b/apps/web/app/api/v1/(legacy)/js/sync/lib/sync.ts
@@ -14,7 +14,7 @@ import { getSurveys, getSyncSurveys } from "@formbricks/lib/survey/service";
import { TEnvironment } from "@formbricks/types/environment";
import { TJsLegacyState, TSurveyWithTriggers } from "@formbricks/types/js";
import { TPerson } from "@formbricks/types/people";
-import { TProduct } from "@formbricks/types/product";
+import { TProductLegacy } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys";
export const transformLegacySurveys = (surveys: TSurvey[]): TSurveyWithTriggers[] => {
@@ -109,7 +109,7 @@ export const getUpdatedState = async (environmentId: string, personId?: string):
throw new Error("Product not found");
}
- const updatedProduct: TProduct = {
+ const updatedProduct: TProductLegacy = {
...product,
brandColor: product.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
...(product.styling.highlightBorderColor?.light && {
diff --git a/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts b/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts
index 0bbc8186cf..901791f39b 100644
--- a/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts
+++ b/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts
@@ -8,7 +8,7 @@ import { NextRequest, userAgent } from "next/server";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { getAttributes } from "@formbricks/lib/attribute/service";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
-import { getEnvironment } from "@formbricks/lib/environment/service";
+import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service";
import {
getMonthlyActiveOrganizationPeopleCount,
getMonthlyOrganizationResponseCount,
@@ -23,7 +23,7 @@ import { isVersionGreaterThanOrEqualTo } from "@formbricks/lib/utils/version";
import { TLegacySurvey } from "@formbricks/types/LegacySurvey";
import { TEnvironment } from "@formbricks/types/environment";
import { TJsAppLegacyStateSync, TJsAppStateSync, ZJsPeopleUserIdInput } from "@formbricks/types/js";
-import { TProduct } from "@formbricks/types/product";
+import { TProductLegacy } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys";
export const OPTIONS = async (): Promise => {
@@ -81,6 +81,10 @@ export const GET = async (
await updateEnvironment(environment.id, { appSetupCompleted: true });
} */
+ if (!environment.appSetupCompleted) {
+ await updateEnvironment(environment.id, { appSetupCompleted: true });
+ }
+
// check organization subscriptions
const organization = await getOrganizationByEnvironmentId(environmentId);
@@ -177,7 +181,7 @@ export const GET = async (
throw new Error("Product not found");
}
- const updatedProduct: TProduct = {
+ const updatedProduct: TProductLegacy = {
...product,
brandColor: product.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
...(product.styling.highlightBorderColor?.light && {
diff --git a/apps/web/app/api/v1/client/[environmentId]/website/sync/route.ts b/apps/web/app/api/v1/client/[environmentId]/website/sync/route.ts
index f13b1f7eca..613f2d49f1 100644
--- a/apps/web/app/api/v1/client/[environmentId]/website/sync/route.ts
+++ b/apps/web/app/api/v1/client/[environmentId]/website/sync/route.ts
@@ -3,7 +3,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { NextRequest } from "next/server";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
-import { getEnvironment } from "@formbricks/lib/environment/service";
+import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service";
import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
@@ -15,7 +15,7 @@ import { getSurveys, transformToLegacySurvey } from "@formbricks/lib/survey/serv
import { isVersionGreaterThanOrEqualTo } from "@formbricks/lib/utils/version";
import { TLegacySurvey } from "@formbricks/types/LegacySurvey";
import { TJsWebsiteLegacyStateSync, TJsWebsiteStateSync, ZJsWebsiteSyncInput } from "@formbricks/types/js";
-import { TProduct } from "@formbricks/types/product";
+import { TProductLegacy } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys";
export const OPTIONS = async (): Promise => {
@@ -89,6 +89,10 @@ export const GET = async (
await updateEnvironment(environment.id, { websiteSetupCompleted: true });
} */
+ if (!environment?.websiteSetupCompleted) {
+ await updateEnvironment(environment.id, { websiteSetupCompleted: true });
+ }
+
const [surveys, actionClasses, product] = await Promise.all([
getSurveys(environmentId),
getActionClasses(environmentId),
@@ -106,7 +110,7 @@ export const GET = async (
// && (!survey.segment || survey.segment.filters.length === 0)
);
- const updatedProduct: TProduct = {
+ const updatedProduct: TProductLegacy = {
...product,
brandColor: product.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
...(product.styling.highlightBorderColor?.light && {
diff --git a/apps/web/app/api/v1/users/route.ts b/apps/web/app/api/v1/users/route.ts
index 6850eada38..1110a712ec 100644
--- a/apps/web/app/api/v1/users/route.ts
+++ b/apps/web/app/api/v1/users/route.ts
@@ -14,7 +14,6 @@ import { deleteInvite } from "@formbricks/lib/invite/service";
import { verifyInviteToken } from "@formbricks/lib/jwt";
import { createMembership } from "@formbricks/lib/membership/service";
import { createOrganization, getOrganization } from "@formbricks/lib/organization/service";
-import { createProduct } from "@formbricks/lib/product/service";
import { createUser, updateUser } from "@formbricks/lib/user/service";
export const POST = async (request: Request) => {
@@ -54,7 +53,6 @@ export const POST = async (request: Request) => {
user = {
...user,
...{ email: user.email.toLowerCase() },
- onboardingCompleted: isInviteValid,
};
// create the user
@@ -119,9 +117,6 @@ export const POST = async (request: Request) => {
if (isMultiOrgEnabled) {
const organization = await createOrganization({ name: user.name + "'s Organization" });
await createMembership(organization.id, user.id, { role: "owner", accepted: true });
- const product = await createProduct(organization.id, {
- name: "My Product",
- });
const updatedNotificationSettings = {
...user.notificationSettings,
@@ -130,7 +125,6 @@ export const POST = async (request: Request) => {
},
weeklySummary: {
...user.notificationSettings?.weeklySummary,
- [product.id]: true,
},
unsubscribedOrganizationIds: Array.from(
new Set([...(user.notificationSettings?.unsubscribedOrganizationIds || []), organization.id])
diff --git a/apps/web/app/middleware/endpointValidator.ts b/apps/web/app/middleware/endpointValidator.ts
index 01fc5a0b23..c5d0cd8c82 100644
--- a/apps/web/app/middleware/endpointValidator.ts
+++ b/apps/web/app/middleware/endpointValidator.ts
@@ -17,7 +17,7 @@ export const shareUrlRoute = (url: string): boolean => {
export const isAuthProtectedRoute = (url: string): boolean => {
// List of routes that require authentication
- const protectedRoutes = ["/environments", "/setup/organization"];
+ const protectedRoutes = ["/environments", "/setup/organization", "/organizations"];
return protectedRoutes.some((route) => url.startsWith(route));
};
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 71d8767069..21a799bf9f 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -2,10 +2,10 @@ import type { Session } from "next-auth";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@formbricks/lib/authOptions";
-import { ONBOARDING_DISABLED } from "@formbricks/lib/constants";
import { getFirstEnvironmentByUserId } from "@formbricks/lib/environment/service";
import { getIsFreshInstance } from "@formbricks/lib/instance/service";
import { getOrganizationsByUserId } from "@formbricks/lib/organization/service";
+import { TEnvironment } from "@formbricks/types/environment";
import { ClientLogout } from "@formbricks/ui/ClientLogout";
const Page = async () => {
@@ -24,17 +24,7 @@ const Page = async () => {
return ;
}
- const userOrganizations = await getOrganizationsByUserId(session.user.id);
-
- if (userOrganizations.length === 0) {
- return redirect("/setup/organization/create");
- }
-
- if (!ONBOARDING_DISABLED && !session.user.onboardingCompleted) {
- return redirect(`/onboarding`);
- }
-
- let environment;
+ let environment: TEnvironment | null = null;
try {
environment = await getFirstEnvironmentByUserId(session?.user.id);
if (!environment) {
@@ -44,9 +34,15 @@ const Page = async () => {
console.error(`error getting environment: ${error}`);
}
+ const userOrganizations = await getOrganizationsByUserId(session.user.id);
+
+ if (userOrganizations.length === 0) {
+ return redirect("/setup/organization/create");
+ }
+
if (!environment) {
console.error("Failed to get first environment of user; signing out");
- return ;
+ return redirect(`/organizations/${userOrganizations[0].id}/products/new/channel`);
}
return redirect(`/environments/${environment.id}`);
diff --git a/apps/web/app/setup/organization/[organizationId]/invite/components/InviteMembers.tsx b/apps/web/app/setup/organization/[organizationId]/invite/components/InviteMembers.tsx
index ad6064b84f..3b73a97dda 100644
--- a/apps/web/app/setup/organization/[organizationId]/invite/components/InviteMembers.tsx
+++ b/apps/web/app/setup/organization/[organizationId]/invite/components/InviteMembers.tsx
@@ -48,11 +48,11 @@ export const InviteMembers = ({ IS_SMTP_CONFIGURED, organizationId }: InviteMemb
}
}
- router.push("/onboarding");
+ router.push("/");
};
const handleSkip = () => {
- router.push("/onboarding");
+ router.push("/");
};
return (
diff --git a/apps/web/app/setup/organization/[organizationId]/invite/page.tsx b/apps/web/app/setup/organization/[organizationId]/invite/page.tsx
index 9a4f800bd9..9ec4c98660 100644
--- a/apps/web/app/setup/organization/[organizationId]/invite/page.tsx
+++ b/apps/web/app/setup/organization/[organizationId]/invite/page.tsx
@@ -22,7 +22,7 @@ const Page = async ({ params }) => {
session.user.id
);
- if (!hasCreateOrUpdateMembersAccess || session.user.onboardingCompleted) return notFound();
+ if (!hasCreateOrUpdateMembersAccess) return notFound();
return ;
};
diff --git a/apps/web/app/setup/organization/create/actions.ts b/apps/web/app/setup/organization/create/actions.ts
index da2f5e7082..dccbc5f6d7 100644
--- a/apps/web/app/setup/organization/create/actions.ts
+++ b/apps/web/app/setup/organization/create/actions.ts
@@ -7,8 +7,6 @@ import { authOptions } from "@formbricks/lib/authOptions";
import { gethasNoOrganizations } from "@formbricks/lib/instance/service";
import { createMembership } from "@formbricks/lib/membership/service";
import { createOrganization } from "@formbricks/lib/organization/service";
-import { createProduct } from "@formbricks/lib/product/service";
-import { updateUser } from "@formbricks/lib/user/service";
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
export const createOrganizationAction = async (organizationName: string): Promise => {
@@ -31,27 +29,5 @@ export const createOrganizationAction = async (organizationName: string): Promis
accepted: true,
});
- const product = await createProduct(newOrganization.id, {
- name: "My Product",
- });
-
- const updatedNotificationSettings = {
- ...session.user.notificationSettings,
- alert: {
- ...session.user.notificationSettings?.alert,
- },
- weeklySummary: {
- ...session.user.notificationSettings?.weeklySummary,
- [product.id]: true,
- },
- unsubscribedOrganizationIds: Array.from(
- new Set([...(session.user.notificationSettings?.unsubscribedOrganizationIds || []), newOrganization.id])
- ),
- };
-
- await updateUser(session.user.id, {
- notificationSettings: updatedNotificationSettings,
- });
-
return newOrganization;
};
diff --git a/apps/web/images/onboarding-survey-bg.jpg b/apps/web/images/onboarding-survey-bg.jpg
new file mode 100644
index 0000000000..a358c5b230
Binary files /dev/null and b/apps/web/images/onboarding-survey-bg.jpg differ
diff --git a/apps/web/playwright/onboarding.spec.ts b/apps/web/playwright/onboarding.spec.ts
index 9e302fe121..1789579097 100644
--- a/apps/web/playwright/onboarding.spec.ts
+++ b/apps/web/playwright/onboarding.spec.ts
@@ -8,13 +8,13 @@ test.describe("Onboarding Flow Test", async () => {
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.waitForURL(/\/organizations\/[^/]+\/products\/new\/channel/);
- await page.getByRole("button", { name: "Link Surveys Create a new" }).click();
- await page.getByRole("button", { name: "Collect Feedback Collect" }).click();
- await page.waitForTimeout(2000);
- await page.getByRole("button", { name: "Publish" }).click();
+ await page.getByRole("button", { name: "100% custom branding Anywhere" }).click();
+ await page.getByRole("button", { name: "B2B and B2C E-Commerce" }).click();
+ await page.getByPlaceholder("Formbricks Merch Store").click();
+ await page.getByPlaceholder("Formbricks Merch Store").fill(productName);
+ await page.locator("form").filter({ hasText: "Brand colorChange the brand" }).getByRole("button").click();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
await expect(page.getByText(productName)).toBeVisible();
@@ -23,17 +23,17 @@ test.describe("Onboarding Flow Test", async () => {
test("website 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: "Website Surveys Run a survey" }).click();
+ await page.waitForURL(/\/organizations\/[^/]+\/products\/new\/channel/);
+ await page.getByRole("button", { name: "Enrich user profiles App with" }).click();
+ await page.getByRole("button", { name: "B2B and B2C E-Commerce" }).click();
+ await page.getByPlaceholder("Formbricks Merch Store").click();
+ await page.getByPlaceholder("Formbricks Merch Store").fill(productName);
+ await page.locator("form").filter({ hasText: "Brand colorChange the brand" }).getByRole("button").click();
await page.getByRole("button", { name: "Skip" }).click();
+ await page.waitForURL(/\/environments\/[^/]+\/connect\/invite/);
await page.getByRole("button", { name: "Skip" }).click();
- await page.getByRole("button", { name: "Skip" }).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.getByText(productName)).toBeVisible();
});
diff --git a/apps/web/playwright/utils/helper.ts b/apps/web/playwright/utils/helper.ts
index ddb9b322b5..7fa9cf8b24 100644
--- a/apps/web/playwright/utils/helper.ts
+++ b/apps/web/playwright/utils/helper.ts
@@ -54,42 +54,17 @@ export const login = async (page: Page, email: string, password: string): Promis
await page.getByRole("button", { name: "Login with Email" }).click();
};
-export const finishOnboarding = async (page: Page, deleteExampleSurvey: boolean = false): Promise => {
- await page.waitForURL("/onboarding");
- await expect(page).toHaveURL("/onboarding");
+export const finishOnboarding = async (page: Page): Promise => {
+ await page.waitForURL(/\/organizations\/[^/]+\/products\/new\/channel/);
- const hiddenSkipButton = page.locator("#FB__INTERNAL__SKIP_ONBOARDING");
- hiddenSkipButton.evaluate((el: HTMLElement) => el.click());
+ await page.getByRole("button", { name: "100% custom branding Anywhere" }).click();
+ await page.getByRole("button", { name: "Proven methods SaaS" }).click();
+ await page.getByPlaceholder("Formbricks Merch Store").click();
+ await page.getByPlaceholder("Formbricks Merch Store").fill("My Product");
+ await page.locator("form").filter({ hasText: "Brand colorChange the brand" }).getByRole("button").click();
- await expect(page.getByText("My Product")).toBeVisible();
-
- let currentDir = process.cwd();
- let htmlFilePath = currentDir + "/packages/js/index.html";
-
- const environmentId =
- /\/environments\/([^/]+)\/surveys/.exec(page.url())?.[1] ??
- (() => {
- throw new Error("Unable to parse environmentId from URL");
- })();
-
- let htmlFile = replaceEnvironmentIdInHtml(htmlFilePath, environmentId);
- await page.goto(htmlFile);
-
- // Formbricks Website Sync has happened
- const syncApi = await page.waitForResponse((response) => response.url().includes("/website/sync"));
- expect(syncApi.status()).toBe(200);
-
- await page.goto("/");
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
-
- if (deleteExampleSurvey) {
- await page.click("#example-website-survey-survey-actions");
- await page.getByRole("menuitem", { name: "Delete" }).click();
- await page.getByRole("button", { name: "Delete" }).click();
- await expect(page.getByText("Survey deleted successfully.")).toBeVisible();
- await page.reload();
- await expect(page.getByText("Start from scratchCreate a")).toBeVisible();
- }
+ await expect(page.getByText("My Product")).toBeVisible();
};
export const replaceEnvironmentIdInHtml = (filePath: string, environmentId: string): string => {
diff --git a/apps/web/playwright/utils/mock.ts b/apps/web/playwright/utils/mock.ts
index 07291c4fb1..8897de6857 100644
--- a/apps/web/playwright/utils/mock.ts
+++ b/apps/web/playwright/utils/mock.ts
@@ -17,6 +17,11 @@ export const users = {
email: "onboarding2@formbricks.com",
password: "231Xh7D&dM8u75EjIYV",
},
+ {
+ name: "Onboarding User 3",
+ email: "onboarding3@formbricks.com",
+ password: "231Xh7D&dM8u75EjIYV",
+ },
],
survey: [
{
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index e1646334cd..068003f64b 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -65,9 +65,6 @@ x-environment: &environment
# Set the below if you want to ship JS & CSS files from a complete URL instead of the current domain
# ASSET_PREFIX_URL:
- # Set the below to 1 to skip onboarding process for new users
- # ONBOARDING_DISABLED: 1
-
# Set the below to your Unsplash API Key for their Survey Backgrounds
# UNSPLASH_ACCESS_KEY:
diff --git a/kamal/deploy.yml b/kamal/deploy.yml
index 2f31f97060..21d683ab85 100644
--- a/kamal/deploy.yml
+++ b/kamal/deploy.yml
@@ -77,7 +77,6 @@ env:
- AIRTABLE_CLIENT_ID
- ENTERPRISE_LICENSE_KEY
- DEFAULT_ORGANIZATION_ID
- - ONBOARDING_DISABLED
- CUSTOMER_IO_API_KEY
- CUSTOMER_IO_SITE_ID
- NEXT_PUBLIC_POSTHOG_API_KEY
diff --git a/packages/database/migrations/20240618115052_remove_onboarding_completed_from_user/migration.sql b/packages/database/migrations/20240618115052_remove_onboarding_completed_from_user/migration.sql
new file mode 100644
index 0000000000..aa21aee227
--- /dev/null
+++ b/packages/database/migrations/20240618115052_remove_onboarding_completed_from_user/migration.sql
@@ -0,0 +1,8 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `onboardingCompleted` on the `User` table. All the data in the column will be lost.
+
+*/
+-- AlterTable
+ALTER TABLE "User" DROP COLUMN "onboardingCompleted";
diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma
index a7794ce89a..bc2163e5fd 100644
--- a/packages/database/schema.prisma
+++ b/packages/database/schema.prisma
@@ -431,8 +431,8 @@ model Product {
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
organizationId String
environments Environment[]
- brandColor String?
- highlightBorderColor String?
+ brandColor String? // deprecated; use styling.brandColor instead
+ highlightBorderColor String? // deprecated
/// @zod.custom(imports.ZProductStyling)
/// [Styling]
styling Json @default("{\"allowStyleOverwrite\":true}")
@@ -586,7 +586,6 @@ model User {
twoFactorEnabled Boolean @default(false)
backupCodes String?
password String?
- onboardingCompleted Boolean @default(false)
identityProvider IdentityProvider @default(email)
identityProviderAccountId String?
memberships Membership[]
diff --git a/packages/email/components/general/email-footer.tsx b/packages/email/components/general/email-footer.tsx
index 89421688fe..8cd6a4cace 100644
--- a/packages/email/components/general/email-footer.tsx
+++ b/packages/email/components/general/email-footer.tsx
@@ -1,11 +1,10 @@
import { Text } from "@react-email/components";
-import React from "react";
export function EmailFooter() {
return (
Have a great day!
-
The Formbricks Team!
+
The Formbricks Team
);
}
diff --git a/packages/email/components/invite/onboarding-invite-email.tsx b/packages/email/components/invite/onboarding-invite-email.tsx
index 65172c377b..efe45beb21 100644
--- a/packages/email/components/invite/onboarding-invite-email.tsx
+++ b/packages/email/components/invite/onboarding-invite-email.tsx
@@ -1,5 +1,4 @@
import { Container, Heading, Text } from "@react-email/components";
-import React from "react";
import { EmailButton } from "../general/email-button";
import { EmailFooter } from "../general/email-footer";
@@ -18,7 +17,7 @@ export function OnboardingInviteEmail({
Hey ๐
{inviteMessage}
- Get Started in Minutes
+ Get Started in Minutes
- Create an account to join {inviterName}'s organization.
- Connect Formbricks to your app or website via HTML Snippet or NPM in just a few minutes.
diff --git a/packages/js/index.html b/packages/js/index.html
index ce06c5c2bf..72030a3263 100644
--- a/packages/js/index.html
+++ b/packages/js/index.html
@@ -7,7 +7,7 @@
e.parentNode.insertBefore(t, e),
setTimeout(function () {
formbricks.init({
- environmentId: "clxjz87mb001v13nh5vxkerjs",
+ environmentId: "clxlr7qq2004nh7aeoh90m13l",
apiHost: "http://localhost:3000",
});
}, 500);
diff --git a/packages/lib/authOptions.ts b/packages/lib/authOptions.ts
index 69667bfe96..a6d1f0e266 100644
--- a/packages/lib/authOptions.ts
+++ b/packages/lib/authOptions.ts
@@ -251,7 +251,6 @@ export const authOptions: NextAuthOptions = {
name: user.name || user.email.split("@")[0],
email: user.email,
emailVerified: new Date(Date.now()),
- onboardingCompleted: false,
identityProvider: provider,
identityProviderAccountId: account.providerAccountId,
});
diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts
index 8d982dd9b1..c31d63921b 100644
--- a/packages/lib/constants.ts
+++ b/packages/lib/constants.ts
@@ -81,7 +81,6 @@ export const TEXT_RESPONSES_PER_PAGE = 5;
export const DEFAULT_ORGANIZATION_ID = env.DEFAULT_ORGANIZATION_ID;
export const DEFAULT_ORGANIZATION_ROLE = env.DEFAULT_ORGANIZATION_ROLE;
-export const ONBOARDING_DISABLED = env.ONBOARDING_DISABLED === "1";
// Storage constants
export const S3_ACCESS_KEY = env.S3_ACCESS_KEY;
diff --git a/packages/lib/env.ts b/packages/lib/env.ts
index 56a426325c..5fd343a04b 100644
--- a/packages/lib/env.ts
+++ b/packages/lib/env.ts
@@ -51,7 +51,6 @@ export const env = createEnv({
OIDC_ISSUER: z.string().optional(),
OIDC_SIGNING_ALGORITHM: z.string().optional(),
OPENTELEMETRY_LISTENER_URL: z.string().optional(),
- ONBOARDING_DISABLED: z.enum(["1", "0"]).optional(),
REDIS_URL: z.string().optional(),
REDIS_HTTP_URL: z.string().optional(),
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
@@ -159,7 +158,6 @@ export const env = createEnv({
OIDC_DISPLAY_NAME: process.env.OIDC_DISPLAY_NAME,
OIDC_ISSUER: process.env.OIDC_ISSUER,
OIDC_SIGNING_ALGORITHM: process.env.OIDC_SIGNING_ALGORITHM,
- ONBOARDING_DISABLED: process.env.ONBOARDING_DISABLED,
REDIS_URL: process.env.REDIS_URL,
REDIS_HTTP_URL: process.env.REDIS_HTTP_URL,
PASSWORD_RESET_DISABLED: process.env.PASSWORD_RESET_DISABLED,
diff --git a/packages/lib/survey/tests/__mock__/survey.mock.ts b/packages/lib/survey/tests/__mock__/survey.mock.ts
index 2285e99ba7..0bb244f196 100644
--- a/packages/lib/survey/tests/__mock__/survey.mock.ts
+++ b/packages/lib/survey/tests/__mock__/survey.mock.ts
@@ -95,7 +95,6 @@ export const mockUser: TUser = {
imageUrl: "https://www.google.com",
createdAt: currentDate,
updatedAt: currentDate,
- onboardingCompleted: true,
twoFactorEnabled: false,
identityProvider: "google",
objective: "improve_user_retention",
diff --git a/packages/lib/user/service.ts b/packages/lib/user/service.ts
index 3c4964081e..c4898bc57e 100644
--- a/packages/lib/user/service.ts
+++ b/packages/lib/user/service.ts
@@ -22,7 +22,6 @@ const responseSelection = {
createdAt: true,
updatedAt: true,
role: true,
- onboardingCompleted: true,
twoFactorEnabled: true,
identityProvider: true,
objective: true,
diff --git a/packages/lib/utils/strings.ts b/packages/lib/utils/strings.ts
index eabf3451a3..e5f56d173f 100644
--- a/packages/lib/utils/strings.ts
+++ b/packages/lib/utils/strings.ts
@@ -20,3 +20,7 @@ export const sanitizeString = (str: string, delimiter: string = "_", length: num
};
export const isCapitalized = (str: string) => str.charAt(0) === str.charAt(0).toUpperCase();
+
+export const startsWithVowel = (str: string): boolean => {
+ return /^[aeiouAEIOU]/.test(str);
+};
diff --git a/packages/lib/utils/users.ts b/packages/lib/utils/users.ts
index 312ca791a7..fecdfc10d9 100644
--- a/packages/lib/utils/users.ts
+++ b/packages/lib/utils/users.ts
@@ -16,7 +16,6 @@ export const createUser = async (
email,
password: hashedPassword,
inviteToken,
- onboardingCompleted: false,
}),
});
if (res.status !== 200) {
diff --git a/packages/surveys/src/components/general/QuestionConditional.tsx b/packages/surveys/src/components/general/QuestionConditional.tsx
index 330a3c2915..f0825471f8 100644
--- a/packages/surveys/src/components/general/QuestionConditional.tsx
+++ b/packages/surveys/src/components/general/QuestionConditional.tsx
@@ -30,7 +30,7 @@ interface QuestionConditionalProps {
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
surveyId: string;
- isInIframe: boolean;
+ autoFocusEnabled: boolean;
currentQuestionId: string;
}
@@ -49,7 +49,7 @@ export const QuestionConditional = ({
setTtc,
surveyId,
onFileUpload,
- isInIframe,
+ autoFocusEnabled,
currentQuestionId,
}: QuestionConditionalProps) => {
if (!value && prefilledQuestionValue) {
@@ -73,7 +73,7 @@ export const QuestionConditional = ({
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
- isInIframe={isInIframe}
+ autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentQuestionId}
/>
) : question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ? (
@@ -89,7 +89,7 @@ export const QuestionConditional = ({
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
- isInIframe={isInIframe}
+ autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentQuestionId}
/>
) : question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ? (
@@ -105,7 +105,7 @@ export const QuestionConditional = ({
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
- isInIframe={isInIframe}
+ autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentQuestionId}
/>
) : question.type === TSurveyQuestionTypeEnum.NPS ? (
@@ -121,7 +121,7 @@ export const QuestionConditional = ({
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
- isInIframe={isInIframe}
+ autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentQuestionId}
/>
) : question.type === TSurveyQuestionTypeEnum.CTA ? (
@@ -137,7 +137,7 @@ export const QuestionConditional = ({
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
- isInIframe={isInIframe}
+ autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentQuestionId}
/>
) : question.type === TSurveyQuestionTypeEnum.Rating ? (
@@ -153,7 +153,7 @@ export const QuestionConditional = ({
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
- isInIframe={isInIframe}
+ autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentQuestionId}
/>
) : question.type === TSurveyQuestionTypeEnum.Consent ? (
@@ -169,7 +169,7 @@ export const QuestionConditional = ({
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
- isInIframe={isInIframe}
+ autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentQuestionId}
/>
) : question.type === TSurveyQuestionTypeEnum.Date ? (
@@ -185,7 +185,7 @@ export const QuestionConditional = ({
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
- isInIframe={isInIframe}
+ autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentQuestionId}
/>
) : question.type === TSurveyQuestionTypeEnum.PictureSelection ? (
@@ -201,7 +201,7 @@ export const QuestionConditional = ({
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
- isInIframe={isInIframe}
+ autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentQuestionId}
/>
) : question.type === TSurveyQuestionTypeEnum.FileUpload ? (
@@ -219,7 +219,7 @@ export const QuestionConditional = ({
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
- isInIframe={isInIframe}
+ autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentQuestionId}
/>
) : question.type === TSurveyQuestionTypeEnum.Cal ? (
@@ -234,7 +234,7 @@ export const QuestionConditional = ({
isLastQuestion={isLastQuestion}
languageCode={languageCode}
ttc={ttc}
- isInIframe={isInIframe}
+ autoFocusEnabled={autoFocusEnabled}
setTtc={setTtc}
currentQuestionId={currentQuestionId}
/>
@@ -264,7 +264,7 @@ export const QuestionConditional = ({
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
- isInIframe={isInIframe}
+ autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentQuestionId}
/>
) : null;
diff --git a/packages/surveys/src/components/general/Survey.tsx b/packages/surveys/src/components/general/Survey.tsx
index 766f44a5ff..33d741d3f2 100644
--- a/packages/surveys/src/components/general/Survey.tsx
+++ b/packages/surveys/src/components/general/Survey.tsx
@@ -39,8 +39,9 @@ export const Survey = ({
clickOutside,
shouldResetQuestionId,
fullSizeCards = false,
+ autoFocus,
}: SurveyBaseProps) => {
- const isInIframe = window.self !== window.top;
+ const autoFocusEnabled = autoFocus !== undefined ? autoFocus : window.self === window.top;
const [questionId, setQuestionId] = useState(() => {
if (startAtQuestionId) {
@@ -260,7 +261,7 @@ export const Survey = ({
survey={survey}
languageCode={selectedLanguage}
responseCount={responseCount}
- isInIframe={isInIframe}
+ autoFocusEnabled={autoFocusEnabled}
replaceRecallInfo={replaceRecallInfo}
/>
);
@@ -282,7 +283,7 @@ export const Survey = ({
videoUrl={survey.thankYouCard.videoUrl}
redirectUrl={survey.redirectUrl}
isRedirectDisabled={isRedirectDisabled}
- isInIframe={isInIframe}
+ autoFocusEnabled={autoFocusEnabled}
/>
);
} else {
@@ -304,7 +305,7 @@ export const Survey = ({
prefilledQuestionValue={getQuestionPrefillData(question.id, offset)}
isLastQuestion={question.id === survey.questions[survey.questions.length - 1].id}
languageCode={selectedLanguage}
- isInIframe={isInIframe}
+ autoFocusEnabled={autoFocusEnabled}
currentQuestionId={questionId}
/>
)
diff --git a/packages/surveys/src/components/general/ThankYouCard.tsx b/packages/surveys/src/components/general/ThankYouCard.tsx
index 7dcfbe94f8..9bc75dc79b 100644
--- a/packages/surveys/src/components/general/ThankYouCard.tsx
+++ b/packages/surveys/src/components/general/ThankYouCard.tsx
@@ -16,7 +16,7 @@ interface ThankYouCardProps {
imageUrl?: string;
videoUrl?: string;
isResponseSendingFinished: boolean;
- isInIframe: boolean;
+ autoFocusEnabled: boolean;
}
export const ThankYouCard = ({
@@ -29,7 +29,7 @@ export const ThankYouCard = ({
imageUrl,
videoUrl,
isResponseSendingFinished,
- isInIframe,
+ autoFocusEnabled,
}: ThankYouCardProps) => {
const media = imageUrl || videoUrl ? : null;
const checkmark = (
@@ -65,7 +65,7 @@ export const ThankYouCard = ({
{
if (!buttonLink) return;
window.location.replace(buttonLink);
diff --git a/packages/surveys/src/components/general/WelcomeCard.tsx b/packages/surveys/src/components/general/WelcomeCard.tsx
index 057e4b1ff6..2eebc4a0bd 100644
--- a/packages/surveys/src/components/general/WelcomeCard.tsx
+++ b/packages/surveys/src/components/general/WelcomeCard.tsx
@@ -16,7 +16,7 @@ interface WelcomeCardProps {
survey: TSurvey;
languageCode: string;
responseCount?: number;
- isInIframe: boolean;
+ autoFocusEnabled: boolean;
replaceRecallInfo: (text: string, responseData: TResponseData) => string;
}
@@ -66,7 +66,7 @@ export const WelcomeCard = ({
languageCode,
survey,
responseCount,
- isInIframe,
+ autoFocusEnabled,
replaceRecallInfo,
}: WelcomeCardProps) => {
const calculateTimeToComplete = () => {
@@ -123,7 +123,7 @@ export const WelcomeCard = ({
{
onSubmit({ ["welcomeCard"]: "clicked" }, {});
}}
diff --git a/packages/surveys/src/components/questions/AddressQuestion.tsx b/packages/surveys/src/components/questions/AddressQuestion.tsx
index 4118e7110f..e946b7f252 100644
--- a/packages/surveys/src/components/questions/AddressQuestion.tsx
+++ b/packages/surveys/src/components/questions/AddressQuestion.tsx
@@ -22,7 +22,7 @@ interface AddressQuestionProps {
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
- isInIframe: boolean;
+ autoFocusEnabled: boolean;
currentQuestionId: string;
}
@@ -37,7 +37,7 @@ export const AddressQuestion = ({
languageCode,
ttc,
setTtc,
- isInIframe,
+ autoFocusEnabled,
currentQuestionId,
}: AddressQuestionProps) => {
const [startTime, setStartTime] = useState(performance.now());
@@ -122,11 +122,11 @@ export const AddressQuestion = ({
const addressTextRef = useCallback(
(currentElement: HTMLInputElement | null) => {
- if (question.id && currentElement && !isInIframe) {
+ if (question.id && currentElement && autoFocusEnabled) {
currentElement.focus();
}
},
- [question.id, isInIframe]
+ [question.id, autoFocusEnabled]
);
return (
@@ -157,7 +157,7 @@ export const AddressQuestion = ({
required={required}
value={safeValue[index] || ""}
onInput={(e) => handleInputChange(e.currentTarget.value, index)}
- autoFocus={!isInIframe && index === 0}
+ autoFocus={autoFocusEnabled && index === 0}
className="border-border focus:border-brand placeholder:text-placeholder text-subheading bg-input-bg rounded-custom block w-full border p-2 shadow-sm sm:text-sm"
/>
))}
diff --git a/packages/surveys/src/components/questions/CTAQuestion.tsx b/packages/surveys/src/components/questions/CTAQuestion.tsx
index 41f1247df0..e46f9dfdd0 100644
--- a/packages/surveys/src/components/questions/CTAQuestion.tsx
+++ b/packages/surveys/src/components/questions/CTAQuestion.tsx
@@ -22,7 +22,7 @@ interface CTAQuestionProps {
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
- isInIframe: boolean;
+ autoFocusEnabled: boolean;
currentQuestionId: string;
}
@@ -36,7 +36,7 @@ export const CTAQuestion = ({
languageCode,
ttc,
setTtc,
- isInIframe,
+ autoFocusEnabled,
currentQuestionId,
}: CTAQuestionProps) => {
const [startTime, setStartTime] = useState(performance.now());
@@ -88,7 +88,7 @@ export const CTAQuestion = ({
{
if (question.buttonExternal && question.buttonUrl) {
window?.open(question.buttonUrl, "_blank")?.focus();
diff --git a/packages/surveys/src/components/questions/CalQuestion.tsx b/packages/surveys/src/components/questions/CalQuestion.tsx
index 3eeb09478e..76698e2477 100644
--- a/packages/surveys/src/components/questions/CalQuestion.tsx
+++ b/packages/surveys/src/components/questions/CalQuestion.tsx
@@ -23,7 +23,7 @@ interface CalQuestionProps {
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
- isInIframe: boolean;
+ autoFocusEnabled: boolean;
currentQuestionId: string;
}
diff --git a/packages/surveys/src/components/questions/ConsentQuestion.tsx b/packages/surveys/src/components/questions/ConsentQuestion.tsx
index 11bcfdf8a4..248b596325 100644
--- a/packages/surveys/src/components/questions/ConsentQuestion.tsx
+++ b/packages/surveys/src/components/questions/ConsentQuestion.tsx
@@ -21,7 +21,7 @@ interface ConsentQuestionProps {
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
- isInIframe: boolean;
+ autoFocusEnabled: boolean;
currentQuestionId: string;
}
diff --git a/packages/surveys/src/components/questions/DateQuestion.tsx b/packages/surveys/src/components/questions/DateQuestion.tsx
index e387656066..756d8deedd 100644
--- a/packages/surveys/src/components/questions/DateQuestion.tsx
+++ b/packages/surveys/src/components/questions/DateQuestion.tsx
@@ -26,7 +26,7 @@ interface DateQuestionProps {
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
- isInIframe: boolean;
+ autoFocusEnabled: boolean;
currentQuestionId: string;
}
diff --git a/packages/surveys/src/components/questions/FileUploadQuestion.tsx b/packages/surveys/src/components/questions/FileUploadQuestion.tsx
index e81cfeadd9..d5eff003a6 100644
--- a/packages/surveys/src/components/questions/FileUploadQuestion.tsx
+++ b/packages/surveys/src/components/questions/FileUploadQuestion.tsx
@@ -25,7 +25,7 @@ interface FileUploadQuestionProps {
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
- isInIframe: boolean;
+ autoFocusEnabled: boolean;
currentQuestionId: string;
}
diff --git a/packages/surveys/src/components/questions/MultipleChoiceMultiQuestion.tsx b/packages/surveys/src/components/questions/MultipleChoiceMultiQuestion.tsx
index 4c58c2afe8..289213ace2 100644
--- a/packages/surveys/src/components/questions/MultipleChoiceMultiQuestion.tsx
+++ b/packages/surveys/src/components/questions/MultipleChoiceMultiQuestion.tsx
@@ -22,7 +22,7 @@ interface MultipleChoiceMultiProps {
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
- isInIframe: boolean;
+ autoFocusEnabled: boolean;
currentQuestionId: string;
}
@@ -37,7 +37,7 @@ export const MultipleChoiceMultiQuestion = ({
languageCode,
ttc,
setTtc,
- isInIframe,
+ autoFocusEnabled,
currentQuestionId,
}: MultipleChoiceMultiProps) => {
const [startTime, setStartTime] = useState(performance.now());
@@ -180,7 +180,7 @@ export const MultipleChoiceMultiQuestion = ({
document.getElementById(choice.id)?.focus();
}
}}
- autoFocus={idx === 0 && !isInIframe}>
+ autoFocus={idx === 0 && autoFocusEnabled}>
void;
- isInIframe: boolean;
+ autoFocusEnabled: boolean;
currentQuestionId: string;
}
@@ -37,7 +37,7 @@ export const MultipleChoiceSingleQuestion = ({
languageCode,
ttc,
setTtc,
- isInIframe,
+ autoFocusEnabled,
currentQuestionId,
}: MultipleChoiceSingleProps) => {
const [startTime, setStartTime] = useState(performance.now());
@@ -145,7 +145,7 @@ export const MultipleChoiceSingleQuestion = ({
document.getElementById(choice.id)?.focus();
}
}}
- autoFocus={idx === 0 && !isInIframe}>
+ autoFocus={idx === 0 && autoFocusEnabled}>
void;
- isInIframe: boolean;
+ autoFocusEnabled: boolean;
currentQuestionId: string;
}
diff --git a/packages/surveys/src/components/questions/OpenTextQuestion.tsx b/packages/surveys/src/components/questions/OpenTextQuestion.tsx
index b9db005198..6929bd26e1 100644
--- a/packages/surveys/src/components/questions/OpenTextQuestion.tsx
+++ b/packages/surveys/src/components/questions/OpenTextQuestion.tsx
@@ -24,7 +24,7 @@ interface OpenTextQuestionProps {
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
- isInIframe: boolean;
+ autoFocusEnabled: boolean;
currentQuestionId: string;
}
@@ -39,7 +39,7 @@ export const OpenTextQuestion = ({
languageCode,
ttc,
setTtc,
- isInIframe,
+ autoFocusEnabled,
currentQuestionId,
}: OpenTextQuestionProps) => {
const [startTime, setStartTime] = useState(performance.now());
@@ -62,11 +62,11 @@ export const OpenTextQuestion = ({
const openTextRef = useCallback(
(currentElement: HTMLInputElement | HTMLTextAreaElement | null) => {
- if (question.id && currentElement && !isInIframe) {
+ if (question.id && currentElement && autoFocusEnabled) {
currentElement.focus();
}
},
- [question.id, isInIframe]
+ [question.id, autoFocusEnabled]
);
return (
@@ -105,7 +105,7 @@ export const OpenTextQuestion = ({
value={value ? (value as string) : ""}
type={question.inputType}
onInput={(e) => handleInputChange(e.currentTarget.value)}
- autoFocus={!isInIframe}
+ autoFocus={autoFocusEnabled}
className="border-border placeholder:text-placeholder text-subheading focus:border-brand bg-input-bg rounded-custom block w-full border p-2 shadow-sm focus:outline-none focus:ring-0 sm:text-sm"
pattern={question.inputType === "phone" ? "[0-9+ ]+" : ".*"}
title={question.inputType === "phone" ? "Enter a valid phone number" : undefined}
@@ -127,7 +127,7 @@ export const OpenTextQuestion = ({
handleInputChange(e.currentTarget.value);
handleInputResize(e);
}}
- autoFocus={!isInIframe}
+ autoFocus={autoFocusEnabled}
className="border-border placeholder:text-placeholder bg-input-bg text-subheading focus:border-brand rounded-custom block w-full border p-2 shadow-sm focus:ring-0 sm:text-sm"
pattern={question.inputType === "phone" ? "[+][0-9 ]+" : ".*"}
title={question.inputType === "phone" ? "Please enter a valid phone number" : undefined}
diff --git a/packages/surveys/src/components/questions/PictureSelectionQuestion.tsx b/packages/surveys/src/components/questions/PictureSelectionQuestion.tsx
index 3f489034bb..2c59b57faa 100644
--- a/packages/surveys/src/components/questions/PictureSelectionQuestion.tsx
+++ b/packages/surveys/src/components/questions/PictureSelectionQuestion.tsx
@@ -22,7 +22,7 @@ interface PictureSelectionProps {
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
- isInIframe: boolean;
+ autoFocusEnabled: boolean;
currentQuestionId: string;
}
diff --git a/packages/surveys/src/components/questions/RatingQuestion.tsx b/packages/surveys/src/components/questions/RatingQuestion.tsx
index c64815f87c..87d2396e76 100644
--- a/packages/surveys/src/components/questions/RatingQuestion.tsx
+++ b/packages/surveys/src/components/questions/RatingQuestion.tsx
@@ -34,7 +34,7 @@ interface RatingQuestionProps {
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
- isInIframe: boolean;
+ autoFocusEnabled: boolean;
currentQuestionId: string;
}
diff --git a/packages/types/product.ts b/packages/types/product.ts
index adc1bafd4e..8de264529f 100644
--- a/packages/types/product.ts
+++ b/packages/types/product.ts
@@ -68,18 +68,42 @@ export const ZProduct = z.object({
clickOutsideClose: z.boolean(),
darkOverlay: z.boolean(),
environments: z.array(ZEnvironment),
- brandColor: ZColor.nullish(),
- highlightBorderColor: ZColor.nullish(),
languages: z.array(ZLanguage),
logo: ZLogo.nullish(),
});
export type TProduct = z.infer;
+export const ZProductLegacy = z.object({
+ id: z.string().cuid2(),
+ createdAt: z.date(),
+ updatedAt: z.date(),
+ name: z.string().trim().min(1, { message: "Product name cannot be empty" }),
+ organizationId: z.string(),
+ styling: ZProductStyling,
+ recontactDays: z
+ .number({ message: "Recontact days is required" })
+ .int()
+ .min(0, { message: "Must be a positive number" })
+ .max(365, { message: "Must be less than 365" }),
+ inAppSurveyBranding: z.boolean(),
+ linkSurveyBranding: z.boolean(),
+ config: ZProductConfig,
+ placement: ZPlacement,
+ clickOutsideClose: z.boolean(),
+ darkOverlay: z.boolean(),
+ environments: z.array(ZEnvironment),
+ brandColor: ZColor.nullish(),
+ highlightBorderColor: ZColor.nullish(),
+ languages: z.array(ZLanguage),
+ logo: ZLogo.nullish(),
+});
+
+export type TProductLegacy = z.infer;
+
export const ZProductUpdateInput = z.object({
- name: z.string().optional(),
+ name: z.string().trim().min(1, { message: "Product name cannot be empty" }).optional(),
organizationId: z.string().optional(),
- brandColor: ZColor.optional(),
highlightBorderColor: ZColor.nullish(),
recontactDays: z.number().int().optional(),
inAppSurveyBranding: z.boolean().optional(),
diff --git a/packages/types/user.ts b/packages/types/user.ts
index 9db194381e..c99c8cfa26 100644
--- a/packages/types/user.ts
+++ b/packages/types/user.ts
@@ -34,7 +34,6 @@ export const ZUser = z.object({
identityProvider: z.enum(["email", "google", "github", "azuread", "openid"]),
createdAt: z.date(),
updatedAt: z.date(),
- onboardingCompleted: z.boolean(),
role: ZRole.nullable(),
objective: ZUserObjective.nullable(),
notificationSettings: ZUserNotificationSettings,
@@ -46,7 +45,6 @@ export const ZUserUpdateInput = z.object({
name: z.string().optional(),
email: z.string().email().optional(),
emailVerified: z.date().nullish(),
- onboardingCompleted: z.boolean().optional(),
role: ZRole.optional(),
objective: ZUserObjective.nullish(),
imageUrl: z.string().nullish(),
@@ -62,7 +60,6 @@ export const ZUserCreateInput = z.object({
.min(1, { message: "Name should be at least 1 character long" }),
email: z.string().email(),
emailVerified: z.date().optional(),
- onboardingCompleted: z.boolean().optional(),
role: ZRole.optional(),
objective: ZUserObjective.nullish(),
identityProvider: z.enum(["email", "google", "github", "azuread", "openid"]).optional(),
diff --git a/packages/ui/Header/index.tsx b/packages/ui/Header/index.tsx
new file mode 100644
index 0000000000..d16a6b7cd6
--- /dev/null
+++ b/packages/ui/Header/index.tsx
@@ -0,0 +1,15 @@
+import React from "react";
+
+interface HeaderProps {
+ title: string;
+ subtitle: string;
+}
+
+export const Header: React.FC = ({ title, subtitle }) => {
+ return (
+
+ );
+};
diff --git a/packages/ui/OptionCard/index.tsx b/packages/ui/OptionCard/index.tsx
index 29dc4a71e1..9ff4feb735 100644
--- a/packages/ui/OptionCard/index.tsx
+++ b/packages/ui/OptionCard/index.tsx
@@ -6,7 +6,7 @@ interface PathwayOptionProps {
title: string;
description: string;
loading?: boolean;
- onSelect: () => void;
+ onSelect?: () => void;
cssId?: string;
children?: React.ReactNode;
}
@@ -29,7 +29,7 @@ export const OptionCard: React.FC = ({
diff --git a/packages/ui/TemplateList/actions.ts b/packages/ui/TemplateList/actions.ts
index 4ae89d66a8..2a0be796b3 100644
--- a/packages/ui/TemplateList/actions.ts
+++ b/packages/ui/TemplateList/actions.ts
@@ -3,16 +3,26 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
+import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
+import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { createSurvey } from "@formbricks/lib/survey/service";
-import { AuthorizationError } from "@formbricks/types/errors";
+import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import { TSurveyInput } from "@formbricks/types/surveys";
export const createSurveyAction = async (environmentId: string, surveyBody: TSurveyInput) => {
const session = await getServerSession(authOptions);
- if (!session) throw new AuthorizationError("Not authorized");
+ if (!session) throw new AuthorizationError("Not authenticated");
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
+ const organization = await getOrganizationByEnvironmentId(environmentId);
+ if (!organization) throw new Error("Organization not found");
+
+ const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
+ if (!membership || membership.role === "viewer") {
+ throw OperationNotAllowedError;
+ }
+
return await createSurvey(environmentId, surveyBody);
};
diff --git a/turbo.json b/turbo.json
index ade03fa19b..ddad7579ee 100644
--- a/turbo.json
+++ b/turbo.json
@@ -58,7 +58,6 @@
"AZUREAD_TENANT_ID",
"DEFAULT_ORGANIZATION_ID",
"DEFAULT_ORGANIZATION_ROLE",
- "ONBOARDING_DISABLED",
"CRON_SECRET",
"CUSTOM_CACHE_DISABLED",
"CUSTOMER_IO_API_KEY",