feat: Product onboarding with XM approach (#2770)

Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Dhruwang Jariwala
2024-06-19 17:59:05 +05:30
committed by GitHub
parent 3ab8092a82
commit f358254e3c
94 changed files with 1301 additions and 1707 deletions

View File

@@ -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=

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -52,7 +52,6 @@ These variables are present inside your machines 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) | |

View File

@@ -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 (
<div className="mt-6 flex w-5/6 flex-col items-center space-y-10 lg:w-2/3 2xl:w-1/2">
<div className="flex w-full space-x-10">
<div className="flex w-1/2 flex-col space-y-4">
<OnboardingSetupInstructions
environmentId={environment.id}
webAppUrl={webAppUrl}
channel={channel}
widgetSetupCompleted={widgetSetupCompleted}
/>
</div>
<div
className={cn(
"flex h-[30rem] w-1/2 flex-col items-center justify-center rounded-lg border bg-slate-200 text-center shadow",
widgetSetupCompleted ? "border-green-500 bg-green-100" : ""
)}>
{widgetSetupCompleted ? (
<div>
<Image src={Dance} alt="lost" height={250} />
<p className="mt-6 text-xl font-bold">Connection successful </p>
</div>
) : (
<div className="space-y-4">
<Image src={Lost} alt="lost" height={250} />
<p className="pt-4 text-slate-400">Waiting for your signal...</p>
</div>
)}
</div>
</div>
<Button
id="finishOnboarding"
variant={widgetSetupCompleted ? "darkCTA" : "minimal"}
onClick={handleFinishOnboarding}
EndIcon={ArrowRight}>
{widgetSetupCompleted ? "Finish Onboarding" : "Skip"}
</Button>
</div>
);
};

View File

@@ -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<typeof ZInviteOrganizationMemberDetails>;
export const InviteOrganizationMember = ({ organization, environmentId }: InviteOrganizationMemberProps) => {
const router = useRouter();
const form = useForm<TInviteOrganizationMemberDetails>({
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 (
<div className="mb-8 w-full max-w-xl space-y-8">
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(handleInvite)} className="w-full space-y-4">
<div className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full space-y-4">
<FormLabel>Email</FormLabel>
<FormControl>
<div>
<Input
value={field.value}
onChange={(email) => field.onChange(email)}
placeholder="engineering@acme.com"
className=" bg-white"
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="inviteMessage"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full space-y-4">
<FormLabel>Invite Message</FormLabel>
<FormControl>
<div>
<textarea
rows={5}
className="focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-transparent bg-white px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300"
value={field.value}
onChange={(inviteMessage) => field.onChange(inviteMessage)}
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
</FormControl>
</FormItem>
)}
/>
<div className="flex w-full justify-end space-x-2">
<Button
id="onboarding-inapp-invite-have-a-look-first"
className="font-normal text-slate-400"
variant="minimal"
onClick={(e) => {
e.preventDefault();
finishOnboarding();
}}>
Skip
</Button>
<Button
id="onboarding-inapp-invite-send-invite"
variant="darkCTA"
type={"submit"}
loading={isSubmitting}>
Invite
</Button>
</div>
</div>
</form>
</FormProvider>
</div>
);
};

View File

@@ -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: <Html5Icon /> },
{ id: "npm", label: "NPM", icon: <NpmIcon /> },
];
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 = `<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){
var apiHost = "${webAppUrl}";
var environmentId = "${environmentId}";
var userId = "testUser";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=apiHost+"/api/packages/app";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: environmentId, apiHost: apiHost, userId: userId})},500)}();
</script>
<!-- END Formbricks Surveys -->
`;
const htmlSnippetForWebsiteSurveys = `<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){
var apiHost = "${webAppUrl}";
var environmentId = "${environmentId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=apiHost+"/api/packages/website";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: environmentId, apiHost: apiHost})},500)}();
</script>
<!-- END Formbricks Surveys -->
`;
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 (
<div>
<div className="flex h-14 w-full items-center justify-center rounded-md border border-slate-200 bg-white">
<TabBar
tabs={tabs}
activeId={activeTab}
setActiveId={setActiveTab}
tabStyle="button"
className="bg-slate-100"
/>
</div>
<div>
{activeTab === "npm" ? (
<div className="prose prose-slate w-full">
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="sh">
npm install @formbricks/js
</CodeBlock>
<p>or</p>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="sh">
yarn add @formbricks/js
</CodeBlock>
<p className="text-sm text-slate-700">
Import Formbricks and initialize the widget in your Component (e.g. App.tsx):
</p>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
{channel === "app" ? npmSnippetForAppSurveys : npmSnippetForWebsiteSurveys}
</CodeBlock>
<Button
id="onboarding-inapp-connect-read-npm-docs"
className="mt-3"
variant="secondary"
href={`https://formbricks.com/docs/${channel}-surveys/framework-guides`}
target="_blank">
Read docs
</Button>
</div>
) : activeTab === "html" ? (
<div className="prose prose-slate">
<p className="-mb-1 mt-6 text-sm text-slate-700">
Insert this code into the &lt;head&gt; tag of your website:
</p>
<div>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
{channel === "app" ? htmlSnippetForAppSurveys : htmlSnippetForWebsiteSurveys}
</CodeBlock>
</div>
<div className="mt-4 flex justify-between space-x-2">
<Button
id="onboarding-inapp-connect-copy-code"
variant={widgetSetupCompleted ? "secondary" : "darkCTA"}
onClick={() => {
navigator.clipboard.writeText(
channel === "app" ? htmlSnippetForAppSurveys : htmlSnippetForWebsiteSurveys
);
toast.success("Copied to clipboard");
}}>
Copy code
</Button>
<Button
id="onboarding-inapp-connect-step-by-step-manual"
variant="secondary"
href={`https://formbricks.com/docs/${channel}-surveys/framework-guides#html`}
target="_blank">
Step by step manual
</Button>
</div>
</div>
) : null}
</div>
</div>
);
};

View File

@@ -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 (
<div className="flex min-h-full min-w-full flex-col items-center justify-center">
<Header
title="Invite your organization to help out"
subtitle="Ask your tech-savvy co-worker to finish the setup:"
/>
<div className="space-y-4 text-center">
<p className="text-4xl font-medium text-slate-800"></p>
<p className="text-sm text-slate-500"></p>
</div>
<InviteOrganizationMember organization={organization} environmentId={params.environmentId} />
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="minimal"
href={`/environments/${params.environmentId}/`}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Button>
</div>
);
};
export default Page;

View File

@@ -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 <div className="flex-1 bg-slate-50">{children}</div>;
};
export default OnboardingLayout;

View File

@@ -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 (
<div className="flex min-h-full flex-col items-center justify-center py-10">
<Header
title={`Let's connect your ${customHeadline} with Formbricks`}
subtitle="If you don't do it now, chances are low that you will ever do it!"
/>
<div className="space-y-4 text-center">
<p className="text-4xl font-medium text-slate-800"></p>
<p className="text-sm text-slate-500"></p>
</div>
<ConnectWithFormbricks
environment={environment}
webAppUrl={WEBAPP_URL}
widgetSetupCompleted={
channel === "app" ? environment.appSetupCompleted : environment.websiteSetupCompleted
}
channel={channel}
/>
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="minimal"
href={`/environments/${environment.id}/`}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Button>
</div>
);
};
export default Page;

View File

@@ -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";
};

View File

@@ -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 (
<div className="flex-1 bg-slate-50">
<ToasterClient />
{children}
</div>
);
};
export default ProductOnboardingLayout;

View File

@@ -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 (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
title="Where do you want to survey people?"
subtitle="Get started with proven best practices 🚀"
/>
<OnboardingOptionsContainer options={channelOptions} />
{products.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="minimal"
href={"/"}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Button>
)}
</div>
);
};
export default Page;

View File

@@ -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 (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title="Which industry do you work for?" subtitle="Get started with proven best practices 🚀" />
<OnboardingOptionsContainer options={industryOptions} />
{products.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="minimal"
href={"/"}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Button>
)}
</div>
);
};
export default Page;

View File

@@ -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<TProductUpdateInput>({
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 (
<div className="mt-6 flex w-5/6 space-x-10 lg:w-2/3 2xl:w-1/2">
<div className="flex w-1/2 flex-col space-y-4">
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(addProduct)} className="w-full space-y-4">
<FormField
control={form.control}
name="styling.brandColor.light"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full space-y-4">
<div>
<FormLabel>Brand color</FormLabel>
<FormDescription>Change the brand color of the survey.</FormDescription>
</div>
<FormControl>
<div>
<ColorPicker
color={field.value || defaultBrandColor}
onChange={(color) => field.onChange(color)}
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full space-y-4">
<div>
<FormLabel>Product Name</FormLabel>
<FormDescription>
What is your {getCustomHeadline(channel, industry)} called ?
</FormDescription>
</div>
<FormControl>
<div>
<Input
value={field.value}
onChange={(name) => field.onChange(name)}
placeholder="Formbricks Merch Store"
className="bg-white"
autoFocus={true}
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
</FormControl>
</FormItem>
)}
/>
<div className="flex w-full justify-end">
<Button variant="darkCTA" loading={isSubmitting} type="submit">
Next
</Button>
</div>
</form>
</FormProvider>
</div>
<div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow">
{logoUrl && (
<Image
src={logoUrl}
alt="Logo"
width={256}
height={56}
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
/>
)}
<p className="text-sm text-slate-400">Preview</p>
<div className="h-3/4 w-3/4">
<SurveyInline
survey={PREVIEW_SURVEY}
styling={{ brandColor: { light: brandColor } }}
isBrandingEnabled={false}
languageCode="default"
onFileUpload={async (file) => file.name}
autoFocus={false}
/>
</div>
</div>
</div>
);
};

View File

@@ -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 (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
{channel === "link" ? (
<Header
title="Match your brand, get 2x more responses."
subtitle="When people recognize your brand, they are much more likely to start and complete responses."
/>
) : (
<Header
title={`You run ${startsWithVowel(customHeadline) ? "an " + customHeadline : "a " + customHeadline}, how exciting!`}
subtitle="Get 2x more responses matching surveys with your brand and UI"
/>
)}
<ProductSettings
organizationId={params.organizationId}
channel={channel}
industry={industry}
defaultBrandColor={DEFAULT_BRAND_COLOR}
/>
{products.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="minimal"
href={"/"}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Button>
)}
</div>
);
};
export default Page;

View File

@@ -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 (
<div
className={`overflow relative flex h-[100vh] flex-col items-center justify-center ${fadeout ? "opacity-0 transition-opacity duration-1000" : "opacity-100"}`}>
<Image src={OnboardingSurveyBg} className="absolute inset-0 h-full w-full" alt="OnboardingSurveyBg" />
<div className="relative h-[60vh] w-[50vh] overflow-auto">
<iframe
onLoad={() => setIsIFrameVisible(true)}
src="https://app.formbricks.com/s/clxcwr22p0cwlpvgekzdab2x5?embed=true"
className="absolute left-0 top-0 h-full w-full overflow-visible border-0"></iframe>
</div>
</div>
);
};

View File

@@ -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 <OnboardingSurvey organizationId={params.organizationId} channel={channel} />;
};
export default Page;

View File

@@ -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;
};

View File

@@ -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<Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>>;
iconText: string;
href: string;
}[];
}
export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => {
return (
<div className="grid w-5/6 grid-cols-3 gap-8 text-center lg:w-2/3">
{options.map((option, index) => {
const Icon = option.icon;
return (
<Link href={option.href}>
<OptionCard
size="md"
key={index}
title={option.title}
description={option.description}
loading={false}>
<div className="flex flex-col items-center">
<Icon className="h-16 w-16 text-slate-600" strokeWidth={0.5} />
<p className="mt-4 w-fit rounded-xl bg-slate-200 px-4 text-sm text-slate-700">
{option.iconText}
</p>
</div>
</OptionCard>
</Link>
);
})}
</div>
);
};

View File

@@ -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);
};

View File

@@ -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<TProduct> => {
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;
};

View File

@@ -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 (
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-10 w-10 text-slate-500">
<PlusCircleIcon className="h-5 w-5" />
</div>
<div>
<div className="text-xl font-medium text-slate-700">Add Product</div>
<div className="text-sm text-slate-500">Create a new product for your organization.</div>
</div>
</div>
</div>
</div>
<form onSubmit={handleSubmit(submitProduct)}>
<div className="flex w-full justify-between space-y-4 rounded-lg p-6">
<div className="grid w-full gap-x-2">
<div>
<Label>Name</Label>
<Input
autoFocus
placeholder="e.g. My New Product"
{...register("name", { required: true })}
value={productName}
onChange={(e) => setProductName(e.target.value)}
/>
</div>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
<Button
type="button"
variant="minimal"
onClick={() => {
setOpen(false);
}}>
Cancel
</Button>
<Button variant="darkCTA" type="submit" loading={loading} disabled={!isProductNameValid}>
Add product
</Button>
</div>
</div>
</form>
</div>
</Modal>
);
};

View File

@@ -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 = ({
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
{!isViewer && (
<DropdownMenuItem onClick={() => setShowAddProductModal(true)} className="rounded-lg">
<DropdownMenuItem onClick={() => handleAddProduct(organization.id)} className="rounded-lg">
<PlusIcon className="mr-2 h-4 w-4" />
<span>Add product</span>
</DropdownMenuItem>
@@ -464,11 +466,6 @@ export const MainNavigation = ({
open={showCreateOrganizationModal}
setOpen={(val) => setShowCreateOrganizationModal(val)}
/>
<AddProductModal
open={showAddProductModal}
setOpen={(val) => setShowAddProductModal(val)}
environmentId={environment.id}
/>
</>
);
};

View File

@@ -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 (
<>

View File

@@ -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;

View File

@@ -37,7 +37,6 @@ export const DeleteProduct = async ({ environmentId, product }: DeleteProductPro
isUserAdminOrOwner={isUserAdminOrOwner}
product={product}
environmentId={environmentId}
userId={session?.user.id ?? ""}
/>
);
};

View File

@@ -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("/");

View File

@@ -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);
};

View File

@@ -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 }) => {
<PHProvider>
<>
{session ? <FormbricksClient session={session} /> : null}
<ToasterClient />
{children}
</>
</PHProvider>

View File

@@ -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<TProductUpdateInput>
) => {
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);
};

View File

@@ -1,16 +0,0 @@
// Filename: IntroSection.tsx
import React from "react";
type OnboardingTitleProps = {
title: string;
subtitle: string;
};
export const OnboardingTitle: React.FC<OnboardingTitleProps> = ({ title, subtitle }) => {
return (
<div className="space-y-4 text-center">
<p className="text-4xl font-medium text-slate-800">{title}</p>
<p className="text-sm text-slate-500">{subtitle}</p>
</div>
);
};

View File

@@ -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 (
<div className="space-y-16 p-6 text-center">
<OnboardingTitle
title="How would you like to start?"
subtitle="You can always use all types of surveys later on."
/>
<div className="flex space-x-8">
<OptionCard
cssId="onboarding-link-survey-card"
size="lg"
title="Link Surveys"
description="Create a new survey and share a link."
onSelect={() => {
handleSelect("link");
}}>
<Image src={LinkMockup} alt="" height={350} />
</OptionCard>
<OptionCard
cssId="onboarding-website-survey-card"
size="lg"
title="Website Surveys"
description="Run a survey on a website."
onSelect={() => {
handleSelect("website");
}}>
<Image src={InappMockup} alt="" height={350} />
</OptionCard>
</div>
</div>
);
};

View File

@@ -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 (
<div className="sticky z-50 mt-6 grid w-11/12 max-w-6xl grid-cols-6 items-center rounded-xl border border-slate-200 bg-white px-6 py-3">
<div className="col-span-2">
<Logo className="ml-4 w-1/2" />
</div>
<div className="col-span-1" />
<div className="col-span-3 flex items-center justify-center gap-8">
<div className="relative grow overflow-hidden rounded-full bg-slate-200">
<ProgressBar progress={progress / 100} barColor="bg-brand-dark" height={2} />
</div>
<span className="text-sm text-slate-800">{progress}% complete</span>
</div>
</div>
);
};

View File

@@ -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 (
<div className="flex w-full max-w-xl flex-col gap-8">
<OnboardingTitle title="We are connected!" subtitle="From now on it's a piece of cake 🍰" />
<div className="w-full space-y-8 rounded-lg border border-emerald-300 bg-emerald-50 p-8 text-center">
<Image src={Dance} alt="Dance" className="rounded-lg" />
<p className="text-lg font-semibold text-emerald-900">Connection successful </p>
</div>
<div className="mt-4 text-right">
<Button
id="onboarding-inapp-connect-connection-successful"
variant="minimal"
loading={isLoading}
onClick={() => {
setIsLoading(true);
goToProduct();
}}>
Next <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
);
};
const NotConnectedState = ({ environment, webAppUrl, jsPackageVersion, goToOrganizationInvitePage }) => {
return (
<div className="mb-8 w-full max-w-xl space-y-8">
<OnboardingTitle title="Connect your website" subtitle="It takes just a few minutes to set it up." />
<div className="flex w-full items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-12 py-3 text-slate-700">
Waiting for your signal...
<Image src={Lost} alt="lost" height={75} />
</div>
<div className="w-full border-b border-slate-200 " />
<SetupInstructionsOnboarding
environmentId={environment.id}
webAppUrl={webAppUrl}
jsPackageVersion={jsPackageVersion}
/>
<div className="flex justify-center">
<Button
id="onboarding-inapp-connect-not-sure-how-to-do-this"
className="mt-8 font-normal text-slate-400"
variant="minimal"
onClick={goToOrganizationInvitePage}>
Skip
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
);
};
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 ? (
<ConnectedState
goToProduct={() => {
goToProduct(router);
}}
/>
) : (
<NotConnectedState
jsPackageVersion={jsPackageVersion}
webAppUrl={webAppUrl}
environment={environment}
goToOrganizationInvitePage={() => {
setCurrentStep(5);
localStorage.setItem("onboardingCurrentStep", "5");
goToOrganizationInvitePage();
}}
/>
);
};

View File

@@ -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 (
<textarea
rows={5}
placeholder="engineering@acme.com"
className="focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-transparent bg-white px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300"
value={value}
onChange={onChange}
/>
);
};
export 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 (
<div className="mb-8 w-full max-w-xl space-y-8">
<OnboardingTitle
title="Invite your organization to help out"
subtitle="Ask your tech-savvy co-worker to finish the setup:"
/>
<div className="flex h-[65vh] flex-col justify-between">
<div className="space-y-4">
<Input
tabIndex={0}
placeholder="engineering@acme.com"
className="w-full bg-white"
value={formState.email}
onChange={(e) => handleInputChange(e, "email")}
/>
<InviteMessageInput
value={formState.inviteMessage}
onChange={(e) => handleInputChange(e, "inviteMessage")}
/>
<div className="flex w-full justify-between">
<Button id="onboarding-inapp-invite-back" variant="minimal" onClick={() => goBackToConnectPage()}>
Back
</Button>
<div className="space-x-2">
<Button
id="onboarding-inapp-invite-have-a-look-first"
className="font-normal text-slate-400"
variant="minimal"
onClick={goToProduct}
loading={isLoading}>
Skip
</Button>
<Button id="onboarding-inapp-invite-send-invite" variant="darkCTA" onClick={handleInvite}>
Invite
</Button>
</div>
</div>
</div>
<div className="mt-auto flex justify-center"></div>
</div>
</div>
);
};

View File

@@ -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: <Html5Icon /> },
{ id: "npm", label: "NPM", icon: <NpmIcon /> },
];
interface SetupInstructionsOnboardingProps {
environmentId: string;
webAppUrl: string;
jsPackageVersion: string;
}
export const SetupInstructionsOnboarding = ({
environmentId,
webAppUrl,
}: SetupInstructionsOnboardingProps) => {
const [activeTab, setActiveId] = useState(tabs[0].id);
const htmlSnippet = `<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="${webAppUrl}/api/packages/website";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: "${environmentId}", apiHost: "${window.location.protocol}//${window.location.host}"})},500)}();
</script>
<!-- END Formbricks Surveys -->`;
return (
<div>
<div className="flex h-14 w-full items-center justify-center rounded-md border border-slate-200 bg-white">
<nav className="flex h-full w-full items-center space-x-4 p-1.5" aria-label="Tabs">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveId(tab.id)}
className={cn(
tab.id === activeTab
? " bg-slate-100 font-semibold text-slate-900"
: "text-slate-500 transition-all duration-300 hover:bg-slate-50 hover:text-slate-700",
"flex h-full w-full items-center justify-center rounded-md px-3 py-2 text-center text-sm font-medium"
)}
aria-current={tab.id === activeTab ? "page" : undefined}>
{tab.icon && <div className="flex h-5 w-5 items-center">{tab.icon}</div>}
{tab.label}
</button>
))}
</nav>
</div>
<div>
{activeTab === "npm" ? (
<div className="prose prose-slate">
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="sh">
npm install @formbricks/js
</CodeBlock>
<p>or</p>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="sh">
yarn add @formbricks/js
</CodeBlock>
<p className="text-sm text-slate-700">
Import Formbricks and initialize the widget in your Component (e.g. App.tsx):
</p>
<CodeBlock
customEditorClass="!bg-white border border-slate-200"
language="js">{`import formbricks from "@formbricks/js/website";
if (typeof window !== "undefined") {
formbricks.init({
environmentId: "${environmentId}",
apiHost: "${webAppUrl}",
});
}`}</CodeBlock>
<Button
id="onboarding-inapp-connect-read-npm-docs"
className="mt-3"
variant="secondary"
href="https://formbricks.com/docs/getting-started/framework-guides"
target="_blank">
Read docs
</Button>
</div>
) : activeTab === "html" ? (
<div className="prose prose-slate">
<p className="-mb-1 mt-6 text-sm text-slate-700">
Insert this code into the &lt;head&gt; tag of your website:
</p>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
{htmlSnippet}
</CodeBlock>
<div className="mt-4 space-x-2">
<Button
id="onboarding-inapp-connect-copy-code"
variant="darkCTA"
onClick={() => {
navigator.clipboard.writeText(htmlSnippet);
toast.success("Copied to clipboard");
}}>
Copy code
</Button>
<Button
id="onboarding-inapp-connect-step-by-step-manual"
variant="secondary"
href="https://formbricks.com/docs/getting-started/framework-guides#html"
target="_blank">
Step by step manual
</Button>
</div>
</div>
) : null}
</div>
</div>
);
};

View File

@@ -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<ObjectiveProps> = ({ formbricksResponseId, user, setCurrentStep }) => {
const objectives: Array<ObjectiveChoice> = [
{ label: "Increase conversion", id: "increase_conversion" },
{ label: "Improve user retention", id: "improve_user_retention" },
{ label: "Increase user adoption", id: "increase_user_adoption" },
{ label: "Sharpen marketing messaging", id: "sharpen_marketing_messaging" },
{ label: "Support sales", id: "support_sales" },
{ label: "Other", id: "other" },
];
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const [isProfileUpdating, setIsProfileUpdating] = useState(false);
const [otherValue, setOtherValue] = useState("");
const fieldsetRef = useRef<HTMLFieldSetElement>(null);
useEffect(() => {
const onKeyDown = handleTabNavigation(fieldsetRef, setSelectedChoice);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [fieldsetRef, setSelectedChoice]);
const next = () => {
setCurrentStep(4);
localStorage.setItem("onboardingCurrentStep", "4");
};
const handleNextClick = async () => {
if (selectedChoice === "Other" && otherValue.trim() === "") {
toast.error("Other value missing");
return;
}
if (selectedChoice) {
const selectedObjective = objectives.find((objective) => objective.label === selectedChoice);
if (selectedObjective) {
try {
setIsProfileUpdating(true);
await updateUserAction({
objective: selectedObjective.id,
name: user.name ?? undefined,
});
setIsProfileUpdating(false);
} catch (e) {
setIsProfileUpdating(false);
console.error(e);
toast.error("An error occured saving your settings");
}
if (formbricksEnabled && env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID && formbricksResponseId) {
const res = await updateResponse(
formbricksResponseId,
{
objective: selectedObjective.id === "other" ? otherValue : selectedObjective.label,
},
true
);
if (!res.ok) {
console.error("Error updating response", res.error);
}
}
next();
}
}
};
return (
<div className="flex w-full max-w-xl flex-col gap-8">
<OnboardingTitle
title="What do you want to achieve?"
subtitle="We suggest templates based on your selection."
/>
<fieldset id="choices" aria-label="What do you want to achieve?" ref={fieldsetRef}>
<legend className="sr-only">Choices</legend>
<div className=" relative space-y-2 rounded-md">
{objectives.map((choice) => (
<label
key={choice.id}
className={cn(
selectedChoice === choice.label
? "z-10 border-slate-400 bg-slate-100"
: "border-slate-200 bg-white hover:bg-slate-50",
"relative flex cursor-pointer flex-col rounded-md border p-4 focus:outline-none"
)}>
<span className="flex items-center">
<input
type="radio"
id={choice.id}
value={choice.label}
checked={choice.label === selectedChoice}
className="checked:text-brand-dark focus:text-brand-dark h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={(e) => {
setSelectedChoice(e.currentTarget.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleNextClick();
}
}}
/>
<span id={`${choice.id}-label`} className="ml-3 text-sm text-slate-700">
{choice.label}
</span>
</span>
{choice.id === "other" && selectedChoice === "Other" && (
<div className="mt-4 w-full">
<Input
className="bg-white"
autoFocus
required
placeholder="Please specify"
value={otherValue}
onChange={(e) => setOtherValue(e.target.value)}
/>
</div>
)}
</label>
))}
</div>
</fieldset>
<div className="flex justify-between">
<Button className="text-slate-500" variant="minimal" onClick={next} id="objective-skip">
Skip
</Button>
<Button
variant="darkCTA"
loading={isProfileUpdating}
disabled={!selectedChoice}
onClick={handleNextClick}
id="onboarding-inapp-objective-next">
Next
</Button>
</div>
</div>
);
};

View File

@@ -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<RoleProps> = ({ setFormbricksResponseId, session, setCurrentStep }) => {
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const [isUpdating, setIsUpdating] = useState(false);
const fieldsetRef = useRef<HTMLFieldSetElement>(null);
const [otherValue, setOtherValue] = useState("");
useEffect(() => {
const onKeyDown = handleTabNavigation(fieldsetRef, setSelectedChoice);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [fieldsetRef, setSelectedChoice]);
const roles: Array<RoleChoice> = [
{ label: "Project Manager", id: "project_manager" },
{ label: "Engineer", id: "engineer" },
{ label: "Founder", id: "founder" },
{ label: "Marketing Specialist", id: "marketing_specialist" },
{ label: "Other", id: "other" },
];
const next = () => {
setCurrentStep(3);
localStorage.setItem("onboardingCurrentStep", "3");
};
const handleNextClick = async () => {
if (selectedChoice === "Other" && otherValue.trim() === "") {
toast.error("Other value missing");
return;
}
if (selectedChoice) {
const selectedRole = roles.find((role) => role.label === selectedChoice);
if (selectedRole) {
try {
setIsUpdating(true);
await updateUserAction({
role: selectedRole.id,
});
setIsUpdating(false);
} catch (e) {
setIsUpdating(false);
toast.error("An error occured saving your settings");
console.error(e);
}
if (formbricksEnabled && env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID) {
const res = await createResponse(env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID, session.user.id, {
role: selectedRole.id === "other" ? otherValue : selectedRole.label,
});
if (res.ok) {
const response = res.data;
setFormbricksResponseId(response.id);
} else {
console.error("Error sending response to Formbricks", res.error);
}
}
next();
}
}
};
return (
<div className="flex w-full max-w-xl flex-col gap-8">
<OnboardingTitle
title="What is your role?"
subtitle="Make your Formbricks experience more personalised."
/>
<fieldset id="choices" aria-label="What is your role?" ref={fieldsetRef}>
<legend className="sr-only">Choices</legend>
<div className="relative space-y-2 rounded-md">
{roles.map((choice) => (
<label
key={choice.id}
htmlFor={choice.id}
className={cn(
selectedChoice === choice.label
? "z-10 border-slate-400 bg-slate-100"
: "border-slate-200 bg-white hover:bg-slate-50",
"relative flex cursor-pointer flex-col rounded-md border p-4 focus:outline-none"
)}>
<span className="flex items-center">
<input
type="radio"
id={choice.id}
value={choice.label}
name="role"
checked={choice.label === selectedChoice}
className="checked:text-brand-dark focus:text-brand-dark h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={(e) => {
setSelectedChoice(e.currentTarget.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleNextClick();
}
}}
/>
<span id={`${choice.id}-label`} className="ml-3 text-sm text-slate-700">
{choice.label}
</span>
</span>
{choice.id === "other" && selectedChoice === "Other" && (
<div className="mt-4 w-full">
<Input
className="bg-white"
autoFocus
placeholder="Please specify"
value={otherValue}
onChange={(e) => setOtherValue(e.target.value)}
/>
</div>
)}
</label>
))}
</div>
</fieldset>
<div className="flex justify-between">
<Button className="text-slate-500" variant="minimal" onClick={next} id="role-skip">
Skip
</Button>
<Button
variant="darkCTA"
loading={isUpdating}
disabled={!selectedChoice}
onClick={handleNextClick}
id="onboarding-inapp-role-next">
Next
</Button>
</div>
</div>
);
};

View File

@@ -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<string | null>(null);
const templateOrder = ["Collect Feedback", "Net Promoter Score (NPS)", "Churn Survey"];
const templateImages = {
"Collect Feedback": FeedbackImage,
"Net Promoter Score (NPS)": NPSImage,
"Churn Survey": ChurnImage,
};
const filteredTemplates = templates
.filter((template) => templateOrder.includes(template.name))
.sort((a, b) => templateOrder.indexOf(a.name) - templateOrder.indexOf(b.name));
const newSurveyFromTemplate = async (template: TTemplate) => {
setLoadingTemplate(template.name);
if (typeof localStorage !== undefined) {
localStorage.removeItem("onboardingPathway");
localStorage.removeItem("onboardingCurrentStep");
}
await finishOnboardingAction();
try {
const survey = await createSurveyFromTemplate(template, environmentId);
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
} catch (e) {
toast.error("An error occurred creating a new survey");
}
};
return (
<div className="flex flex-col items-center space-y-16">
<OnboardingTitle title="Create your first survey" subtitle="Pick a template or start from scratch." />
<div className="grid w-11/12 max-w-6xl grid-cols-3 grid-rows-1 gap-6">
{filteredTemplates.map((template) => {
const TemplateImage = templateImages[template.name];
const cssId = `onboarding-link-template-${template.name.toLowerCase().replace(/ /g, "-")}`;
return (
<OptionCard
cssId={cssId} // Use the generated cssId here
size="md"
key={template.name}
title={template.name}
description={template.description}
onSelect={() => newSurveyFromTemplate(template)}
loading={loadingTemplate === template.name}>
<Image src={TemplateImage} alt={template.name} className="rounded-md border border-slate-300" />
</OptionCard>
);
})}
</div>
<Button
id="onboarding-start-from-scratch"
size="lg"
variant="secondary"
loading={loadingTemplate === "Start from scratch"}
onClick={() => {
newSurveyFromTemplate(customSurvey);
}}>
Start from scratch <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
);
};

View File

@@ -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<string | null>(null);
const [progress, setProgress] = useState<number>(16);
const [formbricksResponseId, setFormbricksResponseId] = useState<string | undefined>();
const [currentStep, setCurrentStep] = useState<number | null>(null);
const [iframeLoaded, setIframeLoaded] = useState(false);
const [iframeVisible, setIframeVisible] = useState(false);
const [fade, setFade] = useState(false);
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 (
<PathwaySelect
setSelectedPathway={setSelectedPathway}
setCurrentStep={setCurrentStep}
isFormbricksCloud={isFormbricksCloud}
/>
);
case 2:
return (
selectedPathway !== "link" && (
<Role
setFormbricksResponseId={setFormbricksResponseId}
session={session}
setCurrentStep={setCurrentStep}
/>
)
);
case 3:
return (
<Objective
formbricksResponseId={formbricksResponseId}
user={user}
setCurrentStep={setCurrentStep}
/>
);
case 4:
return (
<ConnectWithFormbricks
environment={environment}
webAppUrl={webAppUrl}
jsPackageVersion={jsPackageJson.version}
setCurrentStep={setCurrentStep}
/>
);
case 5:
return selectedPathway === "link" ? (
<CreateFirstSurvey environmentId={environment.id} />
) : (
<InviteOrganizationMember
environmentId={environment.id}
organization={organization}
setCurrentStep={setCurrentStep}
/>
);
default:
return null;
}
};
return (
<div className="group flex h-full w-full flex-col items-center bg-slate-50">
<div className="hidden">
<button
id="FB__INTERNAL__SKIP_ONBOARDING"
onClick={async () => {
if (typeof localStorage !== undefined) {
localStorage.removeItem("onboardingPathway");
localStorage.removeItem("onboardingCurrentStep");
}
await finishOnboardingAction();
router.push(`/environments/${environment.id}/surveys`);
}}>
Skip onboarding
</button>
</div>
<OnboardingHeader progress={progress} />
<div className="mt-20 flex w-full justify-center bg-slate-50">
{renderOnboardingStep()}
{iframeVisible && isFormbricksCloud && (
<iframe
src={`https://app.formbricks.com/s/clr737oiseav88up09skt2hxo?userId=${session.user.id}`}
onLoad={() => setIframeLoaded(true)}
style={{
inset: "0",
position: "absolute",
width: "100%",
height: "100%",
border: "0",
zIndex: "40",
transition: "opacity 1s ease",
opacity: fade ? "1" : "0", // 1 for fade in, 0 for fade out
}}></iframe>
)}
</div>
</div>
);
};

View File

@@ -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 (
<div className="h-full w-full bg-slate-50">
<PosthogIdentify session={session} />
<ToasterClient />
{children}
</div>
);
};
export default EnvironmentLayout;

View File

@@ -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 (
<Onboarding
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
session={session}
environment={environment}
user={user}
organization={organization}
webAppUrl={WEBAPP_URL}
/>
);
};
export default Page;

View File

@@ -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);
};

View File

@@ -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(

View File

@@ -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 && {

View File

@@ -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<Response> => {
@@ -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 && {

View File

@@ -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<Response> => {
@@ -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 && {

View File

@@ -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])

View File

@@ -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));
};

View File

@@ -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 <ClientLogout />;
}
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 <ClientLogout />;
return redirect(`/organizations/${userOrganizations[0].id}/products/new/channel`);
}
return redirect(`/environments/${environment.id}`);

View File

@@ -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 (

View File

@@ -22,7 +22,7 @@ const Page = async ({ params }) => {
session.user.id
);
if (!hasCreateOrUpdateMembersAccess || session.user.onboardingCompleted) return notFound();
if (!hasCreateOrUpdateMembersAccess) return notFound();
return <InviteMembers IS_SMTP_CONFIGURED={IS_SMTP_CONFIGURED} organizationId={params.organizationId} />;
};

View File

@@ -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<Organization> => {
@@ -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;
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

View File

@@ -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();
});

View File

@@ -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<void> => {
await page.waitForURL("/onboarding");
await expect(page).toHaveURL("/onboarding");
export const finishOnboarding = async (page: Page): Promise<void> => {
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 => {

View File

@@ -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: [
{

View File

@@ -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:

View File

@@ -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

View File

@@ -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";

View File

@@ -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[]

View File

@@ -1,11 +1,10 @@
import { Text } from "@react-email/components";
import React from "react";
export function EmailFooter() {
return (
<Text>
Have a great day!
<br /> The Formbricks Team!
<br /> The Formbricks Team
</Text>
);
}

View File

@@ -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({
<Container>
<Heading>Hey 👋</Heading>
<Text>{inviteMessage}</Text>
<Text className="text-xl font-medium">Get Started in Minutes</Text>
<Text className="font-medium">Get Started in Minutes</Text>
<ol>
<li>Create an account to join {inviterName}&apos;s organization.</li>
<li>Connect Formbricks to your app or website via HTML Snippet or NPM in just a few minutes.</li>

View File

@@ -7,7 +7,7 @@
e.parentNode.insertBefore(t, e),
setTimeout(function () {
formbricks.init({
environmentId: "clxjz87mb001v13nh5vxkerjs",
environmentId: "clxlr7qq2004nh7aeoh90m13l",
apiHost: "http://localhost:3000",
});
}, 500);

View File

@@ -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,
});

View File

@@ -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;

View File

@@ -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,

View File

@@ -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",

View File

@@ -22,7 +22,6 @@ const responseSelection = {
createdAt: true,
updatedAt: true,
role: true,
onboardingCompleted: true,
twoFactorEnabled: true,
identityProvider: true,
objective: true,

View File

@@ -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);
};

View File

@@ -16,7 +16,6 @@ export const createUser = async (
email,
password: hashedPassword,
inviteToken,
onboardingCompleted: false,
}),
});
if (res.status !== 200) {

View File

@@ -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;

View File

@@ -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}
/>
)

View File

@@ -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 ? <QuestionMedia imgUrl={imageUrl} videoUrl={videoUrl} /> : null;
const checkmark = (
@@ -65,7 +65,7 @@ export const ThankYouCard = ({
<SubmitButton
buttonLabel={buttonLabel}
isLastQuestion={false}
focus={!isInIframe}
focus={autoFocusEnabled}
onClick={() => {
if (!buttonLink) return;
window.location.replace(buttonLink);

View File

@@ -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 = ({
<SubmitButton
buttonLabel={getLocalizedValue(buttonLabel, languageCode)}
isLastQuestion={false}
focus={!isInIframe}
focus={autoFocusEnabled}
onClick={() => {
onSubmit({ ["welcomeCard"]: "clicked" }, {});
}}

View File

@@ -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"
/>
))}

View File

@@ -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 = ({
<SubmitButton
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
focus={!isInIframe}
focus={autoFocusEnabled}
onClick={() => {
if (question.buttonExternal && question.buttonUrl) {
window?.open(question.buttonUrl, "_blank")?.focus();

View File

@@ -23,7 +23,7 @@ interface CalQuestionProps {
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
isInIframe: boolean;
autoFocusEnabled: boolean;
currentQuestionId: string;
}

View File

@@ -21,7 +21,7 @@ interface ConsentQuestionProps {
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
isInIframe: boolean;
autoFocusEnabled: boolean;
currentQuestionId: string;
}

View File

@@ -26,7 +26,7 @@ interface DateQuestionProps {
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
isInIframe: boolean;
autoFocusEnabled: boolean;
currentQuestionId: string;
}

View File

@@ -25,7 +25,7 @@ interface FileUploadQuestionProps {
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
isInIframe: boolean;
autoFocusEnabled: boolean;
currentQuestionId: string;
}

View File

@@ -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}>
<span className="flex items-center text-sm" dir="auto">
<input
type="checkbox"

View File

@@ -22,7 +22,7 @@ interface MultipleChoiceSingleProps {
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => 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}>
<span className="flex items-center text-sm">
<input
tabIndex={-1}

View File

@@ -22,7 +22,7 @@ interface NPSQuestionProps {
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
isInIframe: boolean;
autoFocusEnabled: boolean;
currentQuestionId: string;
}

View File

@@ -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}

View File

@@ -22,7 +22,7 @@ interface PictureSelectionProps {
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
isInIframe: boolean;
autoFocusEnabled: boolean;
currentQuestionId: string;
}

View File

@@ -34,7 +34,7 @@ interface RatingQuestionProps {
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
isInIframe: boolean;
autoFocusEnabled: boolean;
currentQuestionId: string;
}

View File

@@ -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<typeof ZProduct>;
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<typeof ZProductLegacy>;
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(),

View File

@@ -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(),

View File

@@ -0,0 +1,15 @@
import React from "react";
interface HeaderProps {
title: string;
subtitle: string;
}
export const Header: React.FC<HeaderProps> = ({ title, subtitle }) => {
return (
<div className="space-y-8 text-center">
<p className="text-4xl font-medium text-slate-800">{title}</p>
<p className="text-slate-500">{subtitle}</p>
</div>
);
};

View File

@@ -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<PathwayOptionProps> = ({
<div className="relative">
<div
id={cssId}
className={`flex cursor-pointer flex-col items-center justify-center bg-white p-4 hover:scale-105 hover:border-slate-300 ${sizeClasses[size]}`}
className={`flex cursor-pointer flex-col items-center justify-center bg-white p-6 hover:scale-105 hover:border-slate-300 ${sizeClasses[size]}`}
onClick={onSelect}
role="button"
tabIndex={0}>

View File

@@ -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);
};

View File

@@ -58,7 +58,6 @@
"AZUREAD_TENANT_ID",
"DEFAULT_ORGANIZATION_ID",
"DEFAULT_ORGANIZATION_ROLE",
"ONBOARDING_DISABLED",
"CRON_SECRET",
"CUSTOM_CACHE_DISABLED",
"CUSTOMER_IO_API_KEY",