mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-21 13:40:31 -06:00
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:
committed by
GitHub
parent
3ab8092a82
commit
f358254e3c
@@ -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=
|
||||
|
||||
1
.github/workflows/kamal-deploy.yml
vendored
1
.github/workflows/kamal-deploy.yml
vendored
@@ -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 }}
|
||||
|
||||
1
.github/workflows/kamal-setup.yml
vendored
1
.github/workflows/kamal-setup.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -52,7 +52,6 @@ These variables are present inside your machine’s docker-compose file. Restart
|
||||
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
|
||||
| DEFAULT_ORGANIZATION_ID | Automatically assign new users to a specific organization when joining | optional | |
|
||||
| DEFAULT_ORGANIZATION_ROLE | Role of the user in the default organization. | optional | admin |
|
||||
| ONBOARDING_DISABLED | Disables onboarding for new users if set to 1 | optional | |
|
||||
| OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | |
|
||||
| OIDC_CLIENT_ID | Client ID for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
|
||||
| OIDC_CLIENT_SECRET | Secret for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 <head> 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
11
apps/web/app/(app)/(onboarding)/lib/utils.ts
Normal file
11
apps/web/app/(app)/(onboarding)/lib/utils.ts
Normal 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";
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
61
apps/web/app/(app)/(onboarding)/organizations/actions.ts
Normal file
61
apps/web/app/(app)/(onboarding)/organizations/actions.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ export const DeleteProduct = async ({ environmentId, product }: DeleteProductPro
|
||||
isUserAdminOrOwner={isUserAdminOrOwner}
|
||||
product={product}
|
||||
environmentId={environmentId}
|
||||
userId={session?.user.id ?? ""}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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("/");
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 <head> 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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 && {
|
||||
|
||||
@@ -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 && {
|
||||
|
||||
@@ -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 && {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
BIN
apps/web/images/onboarding-survey-bg.jpg
Normal file
BIN
apps/web/images/onboarding-survey-bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 241 KiB |
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
@@ -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[]
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}'s organization.</li>
|
||||
<li>Connect Formbricks to your app or website via HTML Snippet or NPM in just a few minutes.</li>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
e.parentNode.insertBefore(t, e),
|
||||
setTimeout(function () {
|
||||
formbricks.init({
|
||||
environmentId: "clxjz87mb001v13nh5vxkerjs",
|
||||
environmentId: "clxlr7qq2004nh7aeoh90m13l",
|
||||
apiHost: "http://localhost:3000",
|
||||
});
|
||||
}, 500);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -22,7 +22,6 @@ const responseSelection = {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
role: true,
|
||||
onboardingCompleted: true,
|
||||
twoFactorEnabled: true,
|
||||
identityProvider: true,
|
||||
objective: true,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -16,7 +16,6 @@ export const createUser = async (
|
||||
email,
|
||||
password: hashedPassword,
|
||||
inviteToken,
|
||||
onboardingCompleted: false,
|
||||
}),
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" }, {});
|
||||
}}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -23,7 +23,7 @@ interface CalQuestionProps {
|
||||
languageCode: string;
|
||||
ttc: TResponseTtc;
|
||||
setTtc: (ttc: TResponseTtc) => void;
|
||||
isInIframe: boolean;
|
||||
autoFocusEnabled: boolean;
|
||||
currentQuestionId: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ interface ConsentQuestionProps {
|
||||
languageCode: string;
|
||||
ttc: TResponseTtc;
|
||||
setTtc: (ttc: TResponseTtc) => void;
|
||||
isInIframe: boolean;
|
||||
autoFocusEnabled: boolean;
|
||||
currentQuestionId: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ interface DateQuestionProps {
|
||||
languageCode: string;
|
||||
ttc: TResponseTtc;
|
||||
setTtc: (ttc: TResponseTtc) => void;
|
||||
isInIframe: boolean;
|
||||
autoFocusEnabled: boolean;
|
||||
currentQuestionId: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ interface FileUploadQuestionProps {
|
||||
languageCode: string;
|
||||
ttc: TResponseTtc;
|
||||
setTtc: (ttc: TResponseTtc) => void;
|
||||
isInIframe: boolean;
|
||||
autoFocusEnabled: boolean;
|
||||
currentQuestionId: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -22,7 +22,7 @@ interface NPSQuestionProps {
|
||||
languageCode: string;
|
||||
ttc: TResponseTtc;
|
||||
setTtc: (ttc: TResponseTtc) => void;
|
||||
isInIframe: boolean;
|
||||
autoFocusEnabled: boolean;
|
||||
currentQuestionId: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -22,7 +22,7 @@ interface PictureSelectionProps {
|
||||
languageCode: string;
|
||||
ttc: TResponseTtc;
|
||||
setTtc: (ttc: TResponseTtc) => void;
|
||||
isInIframe: boolean;
|
||||
autoFocusEnabled: boolean;
|
||||
currentQuestionId: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ interface RatingQuestionProps {
|
||||
languageCode: string;
|
||||
ttc: TResponseTtc;
|
||||
setTtc: (ttc: TResponseTtc) => void;
|
||||
isInIframe: boolean;
|
||||
autoFocusEnabled: boolean;
|
||||
currentQuestionId: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
15
packages/ui/Header/index.tsx
Normal file
15
packages/ui/Header/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -58,7 +58,6 @@
|
||||
"AZUREAD_TENANT_ID",
|
||||
"DEFAULT_ORGANIZATION_ID",
|
||||
"DEFAULT_ORGANIZATION_ROLE",
|
||||
"ONBOARDING_DISABLED",
|
||||
"CRON_SECRET",
|
||||
"CUSTOM_CACHE_DISABLED",
|
||||
"CUSTOMER_IO_API_KEY",
|
||||
|
||||
Reference in New Issue
Block a user