Skip to content

Commit

Permalink
feat: Product onboarding with XM approach (formbricks#2770)
Browse files Browse the repository at this point in the history
Co-authored-by: Johannes <[email protected]>
Co-authored-by: Johannes <[email protected]>
Co-authored-by: Matthias Nannt <[email protected]>
  • Loading branch information
4 people authored Jun 19, 2024
1 parent 3ab8092 commit f358254
Show file tree
Hide file tree
Showing 94 changed files with 1,300 additions and 1,706 deletions.
3 changes: 0 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/kamal-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/kamal-setup.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
1 change: 0 additions & 1 deletion apps/docs/app/self-hosting/configuration/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) | |
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
);
};
Original file line number Diff line number Diff line change
@@ -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="[email protected]"
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>
);
};
Loading

0 comments on commit f358254

Please sign in to comment.