outsource form-engine in own package

This commit is contained in:
Matthias Nannt
2023-02-06 14:53:48 +01:00
parent a9b9f022b5
commit c898af0390
18 changed files with 1271 additions and 653 deletions

View File

@@ -0,0 +1,28 @@
import { Button } from "@formbricks/ui";
interface EngineButtonsProps {
allowSkip: boolean;
skipAction: () => void;
autoSubmit: boolean;
}
export function EngineButtons({ allowSkip, skipAction, autoSubmit }: EngineButtonsProps) {
return (
<div className="mx-auto mt-8 flex w-full max-w-xl justify-end">
{allowSkip && (
<Button
variant="secondary"
type="button"
className="transition-all ease-in-out hover:scale-105"
onClick={() => skipAction()}>
Skip
</Button>
)}
{!autoSubmit && (
<Button variant="primary" type="submit" className="ml-2 transition-all ease-in-out hover:scale-105">
Next
</Button>
)}
</div>
);
}

View File

@@ -1,3 +1,6 @@
import clsx from "clsx";
import { useMemo } from "react";
import { EngineButtons } from "./EngineButtons";
import { SurveyElement } from "./engineTypes";
interface FeatureSelectionProps {
@@ -7,21 +10,38 @@ interface FeatureSelectionProps {
control: any;
onSubmit: () => void;
disabled: boolean;
allowSkip: boolean;
skipAction: () => void;
autoSubmit: boolean;
loading: boolean;
}
export default function FeatureSelection({ element, field, register }: FeatureSelectionProps) {
export default function FeatureSelection({
element,
field,
register,
allowSkip,
skipAction,
autoSubmit,
loading,
}: FeatureSelectionProps) {
const shuffledOptions = useMemo(
() => (element.options ? getShuffledArray(element.options) : []),
[element.options]
);
return (
<div className="flex flex-col justify-center">
<label
htmlFor={element.id}
className="pb-6 text-center text-lg font-bold text-slate-600 dark:text-slate-300 sm:text-xl md:text-2xl">
{element.label}
</label>
<fieldset className="space-y-5">
<legend className="sr-only">{element.label}</legend>
<div className=" mx-auto grid max-w-5xl grid-cols-1 gap-6 px-2 sm:grid-cols-2">
{element.options &&
element.options.map((option) => (
<div className={clsx(loading && "formbricks-pulse-animation")}>
<div className="flex flex-col justify-center">
<label
htmlFor={element.id}
className="pb-6 text-center text-lg font-bold text-slate-600 dark:text-slate-300 sm:text-xl md:text-2xl">
{element.label}
</label>
<fieldset className="space-y-5">
<legend className="sr-only">{element.label}</legend>
<div className=" mx-auto grid max-w-5xl grid-cols-1 gap-6 px-2 sm:grid-cols-2">
{shuffledOptions.map((option) => (
<label htmlFor={`${element.id}-${option.value}`} key={`${element.id}-${option.value}`}>
<div className="drop-shadow-card duration-120 relative cursor-default rounded-lg border border-gray-200 bg-white p-6 transition-all ease-in-out hover:scale-105 dark:border-slate-700 dark:bg-slate-700">
<div className="absolute right-10">
@@ -48,8 +68,19 @@ export default function FeatureSelection({ element, field, register }: FeatureSe
</div>
</label>
))}
</div>
</fieldset>
</div>
</fieldset>
</div>
<EngineButtons allowSkip={allowSkip} skipAction={skipAction} autoSubmit={autoSubmit} />
</div>
);
}
function getShuffledArray(array: any[]) {
const shuffledArray = [...array];
for (let i = shuffledArray.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffledArray[i], shuffledArray[j]] = [shuffledArray[j], shuffledArray[i]];
}
return shuffledArray;
}

View File

@@ -3,6 +3,7 @@ import { CheckCircleIcon } from "@heroicons/react/20/solid";
import clsx from "clsx";
import { useEffect, useState } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { EngineButtons } from "./EngineButtons";
import { SurveyElement } from "./engineTypes";
interface IconRadioProps {
@@ -11,9 +12,22 @@ interface IconRadioProps {
control: any;
onSubmit: () => void;
disabled: boolean;
allowSkip: boolean;
skipAction: () => void;
autoSubmit: boolean;
loading: boolean;
}
export default function IconRadio({ element, control, onSubmit, disabled }: IconRadioProps) {
export default function IconRadio({
element,
control,
onSubmit,
disabled,
allowSkip,
autoSubmit,
skipAction,
loading,
}: IconRadioProps) {
const value = useWatch({
control,
name: element.name!!,
@@ -26,78 +40,81 @@ export default function IconRadio({ element, control, onSubmit, disabled }: Icon
}, [value, onSubmit, disabled]);
return (
<Controller
name={element.name!}
control={control}
rules={{ required: true }}
render={({ field }: { field: any }) => (
<RadioGroup className="flex flex-col justify-center" {...field}>
<RadioGroup.Label className="pb-6 text-center text-lg font-bold text-slate-600 dark:text-slate-300 sm:text-xl md:text-2xl">
{element.label}
</RadioGroup.Label>
<div className="mx-auto -mt-3 mb-3 text-center text-sm text-slate-500 dark:text-slate-300 md:max-w-lg">
{element.help}
</div>
<div className={clsx(loading && "formbricks-pulse-animation")}>
<Controller
name={element.name!}
control={control}
rules={{ required: true }}
render={({ field }: { field: any }) => (
<RadioGroup className="flex flex-col justify-center" {...field}>
<RadioGroup.Label className="pb-6 text-center text-lg font-bold text-slate-600 dark:text-slate-300 sm:text-xl md:text-2xl">
{element.label}
</RadioGroup.Label>
<div className="mx-auto -mt-3 mb-3 text-center text-sm text-slate-500 dark:text-slate-300 md:max-w-lg">
{element.help}
</div>
<div
className={clsx(
element.options && element.options.length >= 4
? "lg:grid-cols-4"
: element.options?.length === 3
? "lg:grid-cols-3"
: element.options?.length === 2
? "lg:grid-cols-2"
: "lg:grid-cols-1",
"mt-4 grid w-full gap-y-6 sm:gap-x-4"
)}>
{element.options &&
element.options.map((option) => (
<RadioGroup.Option
key={option.value}
value={option.value}
className={({ checked, active }) =>
clsx(
checked ? "border-transparent" : "border-slate-200 dark:border-slate-700",
active ? "border-brand ring-brand ring-2" : "",
"relative flex cursor-pointer rounded-lg border bg-white py-8 shadow-sm transition-all ease-in-out hover:scale-105 focus:outline-none dark:bg-slate-700"
)
}>
{({ checked, active }) => (
<>
<div className="flex flex-1 flex-col justify-center text-slate-500 hover:text-slate-700 dark:text-slate-400 hover:dark:text-slate-200">
{option.frontend?.icon && (
<option.frontend.icon
className="text-brand mx-auto mb-3 h-8 w-8"
aria-hidden="true"
/>
)}
<RadioGroup.Label as="span" className="mx-auto text-sm font-medium ">
{option.label}
</RadioGroup.Label>
</div>
<div
className={clsx(
element.options && element.options.length >= 4
? "lg:grid-cols-4"
: element.options?.length === 3
? "lg:grid-cols-3"
: element.options?.length === 2
? "lg:grid-cols-2"
: "lg:grid-cols-1",
"mt-4 grid w-full gap-y-6 sm:gap-x-4"
)}>
{element.options &&
element.options.map((option) => (
<RadioGroup.Option
key={option.value}
value={option.value}
className={({ checked, active }) =>
clsx(
checked ? "border-transparent" : "border-slate-200 dark:border-slate-700",
active ? "border-brand ring-brand ring-2" : "",
"relative flex cursor-pointer rounded-lg border bg-white py-8 shadow-sm transition-all ease-in-out hover:scale-105 focus:outline-none dark:bg-slate-700"
)
}>
{({ checked, active }) => (
<>
<div className="flex flex-1 flex-col justify-center text-slate-500 hover:text-slate-700 dark:text-slate-400 hover:dark:text-slate-200">
{option.frontend?.icon && (
<option.frontend.icon
className="text-brand mx-auto mb-3 h-8 w-8"
aria-hidden="true"
/>
)}
<RadioGroup.Label as="span" className="mx-auto text-sm font-medium ">
{option.label}
</RadioGroup.Label>
</div>
<CheckCircleIcon
className={clsx(
!checked ? "invisible" : "",
"text-brand absolute -right-2 -top-2 z-10 h-5 w-5 rounded-full bg-white"
)}
aria-hidden="true"
/>
<span
className={clsx(
active ? "border" : "border-2",
checked ? "border-brand" : "border-transparent",
"pointer-events-none absolute -inset-px rounded-lg"
)}
aria-hidden="true"
/>
</>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
)}
/>
<CheckCircleIcon
className={clsx(
!checked ? "invisible" : "",
"text-brand absolute -right-2 -top-2 z-10 h-5 w-5 rounded-full bg-white"
)}
aria-hidden="true"
/>
<span
className={clsx(
active ? "border" : "border-2",
checked ? "border-brand" : "border-transparent",
"pointer-events-none absolute -inset-px rounded-lg"
)}
aria-hidden="true"
/>
</>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
)}
/>
<EngineButtons allowSkip={allowSkip} skipAction={skipAction} autoSubmit={autoSubmit} />
</div>
);
}

View File

@@ -1,3 +1,5 @@
import clsx from "clsx";
import { EngineButtons } from "./EngineButtons";
import { SurveyElement } from "./engineTypes";
interface TextareaProps {
@@ -5,25 +7,42 @@ interface TextareaProps {
field: any;
register: any;
disabled: boolean;
allowSkip: boolean;
skipAction: () => void;
onSubmit: () => void;
autoSubmit: boolean;
loading: boolean;
}
export default function Input({ element, field, register, disabled, onSubmit }: TextareaProps) {
export default function Input({
element,
field,
register,
disabled,
onSubmit,
skipAction,
allowSkip,
autoSubmit,
loading,
}: TextareaProps) {
return (
<div className="flex flex-col justify-center">
<label
htmlFor={element.id}
className="pb-6 text-center text-lg font-bold text-slate-600 dark:text-slate-300 sm:text-xl md:text-2xl">
{element.label}
</label>
<input
type={element.frontend?.type || "text"}
onBlur=""
className="focus:border-brand focus:ring-brand mx-auto mt-4 block w-full max-w-xl rounded-md border-gray-300 text-slate-700 shadow-sm dark:bg-slate-700 dark:text-slate-200 dark:placeholder:text-slate-400 dark:focus:bg-slate-700 dark:active:bg-slate-700 sm:text-sm"
placeholder={element.frontend?.placeholder || ""}
required={!!element.frontend?.required}
{...register(element.name!)}
/>
<div className={clsx(loading && "formbricks-pulse-animation")}>
<div className="flex flex-col justify-center">
<label
htmlFor={element.id}
className="pb-6 text-center text-lg font-bold text-slate-600 dark:text-slate-300 sm:text-xl md:text-2xl">
{element.label}
</label>
<input
type={element.frontend?.type || "text"}
onBlur=""
className="focus:border-brand focus:ring-brand mx-auto mt-4 block w-full max-w-xl rounded-md border-gray-300 text-slate-700 shadow-sm dark:bg-slate-700 dark:text-slate-200 dark:placeholder:text-slate-400 dark:focus:bg-slate-700 dark:active:bg-slate-700 sm:text-sm"
placeholder={element.frontend?.placeholder || ""}
required={!!element.frontend?.required}
{...register(element.name!)}
/>
</div>
<EngineButtons allowSkip={allowSkip} skipAction={skipAction} autoSubmit={autoSubmit} />
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { CheckCircleIcon } from "@heroicons/react/20/solid";
import clsx from "clsx";
import { useEffect, useState } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { EngineButtons } from "./EngineButtons";
import { SurveyElement } from "./engineTypes";
interface IconRadioProps {
@@ -11,9 +12,22 @@ interface IconRadioProps {
control: any;
onSubmit: () => void;
disabled: boolean;
allowSkip: boolean;
skipAction: () => void;
autoSubmit: boolean;
loading: boolean;
}
export default function Scale({ element, control, onSubmit, disabled }: IconRadioProps) {
export default function Scale({
element,
control,
onSubmit,
disabled,
allowSkip,
skipAction,
autoSubmit,
loading,
}: IconRadioProps) {
const value = useWatch({
control,
name: element.name!!,
@@ -25,92 +39,95 @@ export default function Scale({ element, control, onSubmit, disabled }: IconRadi
}
}, [value, onSubmit, disabled]);
return (
<Controller
name={element.name!}
control={control}
rules={{ required: true }}
render={({ field }: { field: any }) => (
<RadioGroup className="flex flex-col justify-center" {...field}>
<RadioGroup.Label className="pb-6 text-center text-lg font-bold text-slate-600 dark:text-slate-300 sm:text-xl md:text-2xl">
{element.label}
</RadioGroup.Label>
<div
className={clsx(
element.frontend.max &&
element.frontend.min &&
element.frontend.max - element.frontend.min + 1 >= 11
? "grid-cols-11"
: element.frontend.max - element.frontend.min + 1 === 10
? "grid-cols-10"
: element.frontend.max - element.frontend.min + 1 === 9
? "grid-cols-9"
: element.frontend.max - element.frontend.min + 1 === 8
? "grid-cols-8"
: element.frontend.max - element.frontend.min + 1 === 7
? "grid-cols-7"
: element.frontend.max - element.frontend.min + 1 === 6
? "grid-cols-6"
: element.frontend.max - element.frontend.min + 1 === 5
? "grid-cols-5"
: element.frontend.max - element.frontend.min + 1 === 4
? "grid-cols-4"
: element.frontend.max - element.frontend.min + 1 === 3
? "grid-cols-3"
: element.frontend.max - element.frontend.min + 1 === 2
? "grid-cols-2"
: "grid-cols-1",
"mt-4 grid w-full gap-x-1 sm:gap-x-2"
)}>
{Array.from(
{ length: element.frontend.max - element.frontend.min + 1 },
(_, i) => i + element.frontend.min
).map((num) => (
<RadioGroup.Option
key={num}
value={num}
className={({ checked, active }) =>
clsx(
checked ? "border-transparent" : "border-gray-200 dark:border-slate-700",
active ? "border-brand ring-brand ring-2" : "",
"xs:rounded-lg relative flex cursor-pointer rounded-md border bg-white py-3 shadow-sm transition-all ease-in-out hover:scale-105 focus:outline-none dark:bg-slate-700 sm:p-4"
)
}>
{({ checked, active }) => (
<>
<div className="flex flex-1 flex-col justify-center">
<RadioGroup.Label
as="span"
className="mx-auto text-sm font-medium text-gray-900 dark:text-gray-200">
{num}
</RadioGroup.Label>
</div>
<div className={clsx(loading && "formbricks-pulse-animation")}>
<Controller
name={element.name!}
control={control}
rules={{ required: true }}
render={({ field }: { field: any }) => (
<RadioGroup className="flex flex-col justify-center" {...field}>
<RadioGroup.Label className="pb-6 text-center text-lg font-bold text-slate-600 dark:text-slate-300 sm:text-xl md:text-2xl">
{element.label}
</RadioGroup.Label>
<div
className={clsx(
element.frontend.max &&
element.frontend.min &&
element.frontend.max - element.frontend.min + 1 >= 11
? "grid-cols-11"
: element.frontend.max - element.frontend.min + 1 === 10
? "grid-cols-10"
: element.frontend.max - element.frontend.min + 1 === 9
? "grid-cols-9"
: element.frontend.max - element.frontend.min + 1 === 8
? "grid-cols-8"
: element.frontend.max - element.frontend.min + 1 === 7
? "grid-cols-7"
: element.frontend.max - element.frontend.min + 1 === 6
? "grid-cols-6"
: element.frontend.max - element.frontend.min + 1 === 5
? "grid-cols-5"
: element.frontend.max - element.frontend.min + 1 === 4
? "grid-cols-4"
: element.frontend.max - element.frontend.min + 1 === 3
? "grid-cols-3"
: element.frontend.max - element.frontend.min + 1 === 2
? "grid-cols-2"
: "grid-cols-1",
"mt-4 grid w-full gap-x-1 sm:gap-x-2"
)}>
{Array.from(
{ length: element.frontend.max - element.frontend.min + 1 },
(_, i) => i + element.frontend.min
).map((num) => (
<RadioGroup.Option
key={num}
value={num}
className={({ checked, active }) =>
clsx(
checked ? "border-transparent" : "border-gray-200 dark:border-slate-700",
active ? "border-brand ring-brand ring-2" : "",
"xs:rounded-lg relative flex cursor-pointer rounded-md border bg-white py-3 shadow-sm transition-all ease-in-out hover:scale-105 focus:outline-none dark:bg-slate-700 sm:p-4"
)
}>
{({ checked, active }) => (
<>
<div className="flex flex-1 flex-col justify-center">
<RadioGroup.Label
as="span"
className="mx-auto text-sm font-medium text-gray-900 dark:text-gray-200">
{num}
</RadioGroup.Label>
</div>
<CheckCircleIcon
className={clsx(
!checked ? "invisible" : "",
"text-brand absolute -right-2 -top-2 z-10 h-5 w-5 rounded-full bg-white"
)}
aria-hidden="true"
/>
<span
className={clsx(
active ? "border" : "border-2",
checked ? "border-brand" : "border-transparent",
"pointer-events-none absolute -inset-px rounded-lg"
)}
aria-hidden="true"
/>
</>
)}
</RadioGroup.Option>
))}
</div>
<div className="xs:text-sm mt-2 flex justify-between text-xs text-gray-700 dark:text-slate-400">
<p>{element.frontend.minLabel}</p>
<p>{element.frontend.maxLabel}</p>
</div>
</RadioGroup>
)}
/>
<CheckCircleIcon
className={clsx(
!checked ? "invisible" : "",
"text-brand absolute -right-2 -top-2 z-10 h-5 w-5 rounded-full bg-white"
)}
aria-hidden="true"
/>
<span
className={clsx(
active ? "border" : "border-2",
checked ? "border-brand" : "border-transparent",
"pointer-events-none absolute -inset-px rounded-lg"
)}
aria-hidden="true"
/>
</>
)}
</RadioGroup.Option>
))}
</div>
<div className="xs:text-sm mt-2 flex justify-between text-xs text-gray-700 dark:text-slate-400">
<p>{element.frontend.minLabel}</p>
<p>{element.frontend.maxLabel}</p>
</div>
</RadioGroup>
)}
/>
<EngineButtons allowSkip={allowSkip} skipAction={skipAction} autoSubmit={autoSubmit} />
</div>
);
}

View File

@@ -1,27 +1,44 @@
import clsx from "clsx";
import { useEffect, useState } from "react";
import { EngineButtons } from "./EngineButtons";
import { SurveyElement } from "./engineTypes";
interface TextareaProps {
element: SurveyElement;
register: any;
onSubmit: () => void;
allowSkip: boolean;
skipAction: () => void;
autoSubmit: boolean;
loading: boolean;
}
export default function Textarea({ element, register, onSubmit }: TextareaProps) {
export default function Textarea({
element,
register,
onSubmit,
allowSkip,
skipAction,
autoSubmit,
loading,
}: TextareaProps) {
return (
<div className="flex flex-col justify-center">
<label
htmlFor={element.id}
className="pb-6 text-center text-lg font-bold text-slate-600 dark:text-slate-300 sm:text-xl md:text-2xl">
{element.label}
</label>
<textarea
rows={element.frontend?.rows || 4}
className="focus:border-brand focus:ring-brand mx-auto mt-4 block w-full max-w-xl rounded-md border-gray-300 text-slate-700 shadow-sm dark:bg-slate-700 dark:text-slate-200 dark:placeholder:text-slate-400 sm:text-sm"
placeholder={element.frontend?.placeholder || ""}
required={!!element.frontend?.required}
{...register(element.name!)}
/>
<div className={clsx(loading && "formbricks-pulse-animation")}>
<div className="flex flex-col justify-center">
<label
htmlFor={element.id}
className="pb-6 text-center text-lg font-bold text-slate-600 dark:text-slate-300 sm:text-xl md:text-2xl">
{element.label}
</label>
<textarea
rows={element.frontend?.rows || 4}
className="focus:border-brand focus:ring-brand mx-auto mt-4 block w-full max-w-xl rounded-md border-gray-300 text-slate-700 shadow-sm dark:bg-slate-700 dark:text-slate-200 dark:placeholder:text-slate-400 sm:text-sm"
placeholder={element.frontend?.placeholder || ""}
required={!!element.frontend?.required}
{...register(element.name!)}
/>
</div>
<EngineButtons allowSkip={allowSkip} skipAction={skipAction} autoSubmit={autoSubmit} />
</div>
);
}

View File

@@ -11,6 +11,7 @@
},
"dependencies": {
"@docsearch/react": "^3.3.2",
"@formbricks/engine-react": "workspace:*",
"@formbricks/pmf": "workspace:*",
"@formbricks/react": "workspace:*",
"@formbricks/ui": "workspace:*",

View File

@@ -2,7 +2,7 @@ import FeatureSelection from "@/components/engine/FeatureSelection";
import IconRadio from "@/components/engine/IconRadio";
import Input from "@/components/engine/Input";
import Scale from "@/components/engine/Scale";
import { Survey } from "@/components/engine/Survey";
import { FormbricksEngine } from "@formbricks/engine-react";
import Textarea from "@/components/engine/Textarea";
import ThankYouHeading from "@/components/engine/ThankYouHeading";
import ThankYouPlans from "@/components/engine/ThankYouPlans";
@@ -29,471 +29,480 @@ import {
CrossMarkIcon,
UserCoupleIcon,
} from "@formbricks/ui";
import { usePlausible } from "next-plausible";
const WaitlistPage = () => (
<LayoutWaitlist title="Waitlist" description="Join our Waitlist today">
<div className="mx-auto w-full max-w-5xl px-6 md:w-3/4">
<div className="px-4 pt-20 pb-4">
<h1 className="text-3xl font-bold tracking-tight text-slate-800 dark:text-slate-200 sm:text-4xl md:text-5xl">
<span className="xl:inline">Get</span>{" "}
<span className="from-brand-light to-brand-dark bg-gradient-to-b bg-clip-text text-transparent xl:inline">
early
</span>{" "}
<span className="inline ">access</span>
</h1>
<p className="mt-3 text-sm text-slate-400 dark:text-slate-300 md:text-base">
We are onboarding users continuously. Tell us more about you!
</p>
</div>
const WaitlistPage = () => {
const plausible = usePlausible();
return (
<LayoutWaitlist title="Waitlist" description="Join our Waitlist today">
<div className="mx-auto w-full max-w-5xl px-6 md:w-3/4">
<div className="px-4 pt-20 pb-4">
<h1 className="text-3xl font-bold tracking-tight text-slate-800 dark:text-slate-200 sm:text-4xl md:text-5xl">
<span className="xl:inline">Get</span>{" "}
<span className="from-brand-light to-brand-dark bg-gradient-to-b bg-clip-text text-transparent xl:inline">
early
</span>{" "}
<span className="inline ">access</span>
</h1>
<p className="mt-3 text-sm text-slate-400 dark:text-slate-300 md:text-base">
We are onboarding users continuously. Tell us more about you!
</p>
</div>
<div className="mx-auto my-6 w-full max-w-5xl rounded-xl bg-slate-100 px-8 py-10 dark:bg-slate-800 md:my-12 md:px-16 md:py-20">
<Survey
formbricksUrl={
process.env.NODE_ENV === "production" ? "https://app.formbricks.com" : "http://localhost:3000"
}
formId={
process.env.NODE_ENV === "production" ? "cld37mt2i0000ld08p9q572bc" : "cldonm4ra000019axa4oc440z"
}
survey={{
config: {
progressBar: false,
},
pages: [
{
id: "rolePage",
config: {
autoSubmit: true,
},
elements: [
{
id: "role",
type: "radio",
label: "How would you describe your role?",
name: "role",
options: [
{ label: "Founder", value: "founder", frontend: { icon: FounderIcon } },
{
label: "Product Manager",
value: "productManager",
frontend: { icon: LaptopWorkerIcon },
},
{ label: "Engineer", value: "engineer", frontend: { icon: EngineerIcon } },
],
component: IconRadio,
},
],
<div className="mx-auto my-6 w-full max-w-5xl rounded-xl bg-slate-100 px-8 py-10 dark:bg-slate-800 md:my-12 md:px-16 md:py-20">
<FormbricksEngine
formbricksUrl={
process.env.NODE_ENV === "production" ? "https://app.formbricks.com" : "http://localhost:3000"
}
formId={
process.env.NODE_ENV === "production"
? "cld37mt2i0000ld08p9q572bc"
: "cldonm4ra000019axa4oc440z"
}
onPageSubmit={({ page }) => plausible(`waitlistSubmitPage-${page.id}`)}
onFinished={() => plausible("waitlistFinished")}
schema={{
config: {
progressBar: false,
},
{
id: "targetGroupPage",
config: {
autoSubmit: true,
},
elements: [
{
id: "targetGroup",
type: "radio",
label: "Who are you serving?",
name: "targetGroup",
options: [
{ label: "Companies", value: "companies", frontend: { icon: SkyscraperIcon } },
{ label: "Consumers", value: "consumers", frontend: { icon: UserGroupIcon } },
],
component: IconRadio,
pages: [
{
id: "rolePage",
config: {
autoSubmit: true,
},
],
},
{
id: "emailPage",
config: {
addFieldsToCustomer: ["email"],
},
elements: [
{
id: "email",
type: "text",
label: "What's your email?",
name: "email",
frontend: {
required: true,
type: "email",
placeholder: "email@example.com",
elements: [
{
id: "role",
type: "radio",
label: "How would you describe your role?",
name: "role",
options: [
{ label: "Founder", value: "founder", frontend: { icon: FounderIcon } },
{
label: "Product Manager",
value: "productManager",
frontend: { icon: LaptopWorkerIcon },
},
{ label: "Engineer", value: "engineer", frontend: { icon: EngineerIcon } },
],
component: IconRadio,
},
component: Input,
},
],
},
{
id: "featureSelectionPage",
elements: [
{
id: "featureSelection",
type: "radio",
label: "Select the Best Practices you need:",
name: "featureSelection",
options: [
{
label: "Onboarding Segmentation",
value: "onboardingSegmentation",
frontend: {
icon: OnboardingIcon,
description:
"Get to know your users right from the start. Ask a few questions early, let us enrich the profile.",
},
},
{
label: "Superhuman PMF Engine",
value: "pmf",
frontend: {
icon: PMFIcon,
description:
"Find out how disappointed people would be if they could not use your service any more.",
},
},
{
label: "Feature Chaser",
value: "featureChaser",
frontend: {
icon: DogChaserIcon,
description: "Show a survey about a new feature shown only to people who used it.",
},
},
{
label: "Cancel Subscription Flow",
value: "cancelSubscriptionFlow",
frontend: {
icon: CancelSubscriptionIcon,
description:
"Request users going through a cancel subscription flow before cancelling.",
},
},
{
label: "Interview Prompt",
value: "interviewPrompt",
frontend: {
icon: InterviewPromptIcon,
description:
"Ask high-interest users to book a time in your calendar to get all the juicy details.",
},
},
{
label: "Fake Door Follow-Up",
value: "fakeDoorFollowUp",
frontend: {
icon: DoorIcon,
description:
"Running a fake door experiment? Catch users right when they are full of expectations.",
},
},
{
label: "FeedbackBox",
value: "feedbackBox",
frontend: {
icon: FeedbackIcon,
description: "Give users the chance to share feedback in a single click.",
},
},
{
label: "Bug Report Form",
value: "bugReportForm",
frontend: {
icon: BugBlueIcon,
description: "Catch all bugs in your SaaS with easy and accessible bug reports.",
},
},
{
label: "Rage Click Survey",
value: "rageClickSurvey",
frontend: {
icon: AngryBirdRageIcon,
description:
"Sometimes things dont work. Trigger this rage click survey to catch users in rage.",
},
},
{
label: "Feature Request Widget",
value: "featureRequestWidget",
frontend: {
icon: FeatureRequestIcon,
description:
"Allow users to request features and pipe it to GitHub projects or Linear.",
},
},
],
component: FeatureSelection,
},
],
},
{
id: "wauPage",
config: {
autoSubmit: true,
],
},
elements: [
{
id: "wau",
type: "radio",
label: "How many weekly active users do you have?",
name: "wau",
options: [
{ label: "Not launched", value: "notLaunched", frontend: { icon: CrossMarkIcon } },
{ label: "10-100", value: "10-100", frontend: { icon: UserCoupleIcon } },
{ label: "100-1.000", value: "100-1000", frontend: { icon: UserGroupIcon } },
{ label: "1.000+", value: "10000+", frontend: { icon: UserGroupIcon } },
],
component: IconRadio,
{
id: "targetGroupPage",
config: {
autoSubmit: true,
},
],
},
{
id: "goalPage",
config: {
autoSubmit: true,
},
elements: [
{
id: "goal",
type: "radio",
label: "Want to become a beta user?",
help: "Answer 3 open-ended questions and get 50% off in the first year.",
name: "goal",
options: [
{
label: "No, just notify me on launch",
value: "justNotify",
frontend: { icon: BellIcon },
},
{
label: "Yes, take the survey",
value: "becomeBetaUser",
frontend: { icon: UserCommentIcon },
},
],
component: IconRadio,
},
],
branchingRules: [
{
type: "value",
name: "goal",
value: "justNotify",
nextPageId: "thankYouPageNotify",
},
],
},
{
id: "namePage",
elements: [
{
id: "name",
type: "text",
label: "First of all, whats your name?",
name: "name",
frontend: { placeholder: "First name" },
component: Input,
},
],
},
{
id: "urgencyPage",
config: {
autoSubmit: true,
},
elements: [
{
id: "urgency",
type: "radio",
label: "How urgently do you need this?",
name: "urgency",
options: [
{ label: "1", value: "1" },
{ label: "2", value: "2" },
{ label: "3", value: "3" },
{ label: "4", value: "4" },
{ label: "5", value: "5" },
{ label: "6", value: "6" },
{ label: "7", value: "7" },
{ label: "8", value: "8" },
{ label: "9", value: "9" },
{ label: "10", value: "10" },
],
frontend: {
min: 1,
max: 10,
minLabel: "Im just curious",
maxLabel: "As soon as possible",
elements: [
{
id: "targetGroup",
type: "radio",
label: "Who are you serving?",
name: "targetGroup",
options: [
{ label: "Companies", value: "companies", frontend: { icon: SkyscraperIcon } },
{ label: "Consumers", value: "consumers", frontend: { icon: UserGroupIcon } },
],
component: IconRadio,
},
component: Scale,
},
],
},
{
id: "pmfPage",
config: {
autoSubmit: true,
],
},
elements: [
{
id: "pmf",
type: "radio",
label: "Have you found Product-Market-Fit?",
name: "pmf",
options: [
{
label: "Yes",
value: "yes",
frontend: { icon: CheckMarkIcon },
{
id: "emailPage",
config: {
addFieldsToCustomer: ["email"],
},
elements: [
{
id: "email",
type: "text",
label: "What's your email?",
name: "email",
frontend: {
required: true,
type: "email",
placeholder: "email@example.com",
},
{
label: "No",
value: "no",
frontend: { icon: CrossMarkIcon },
component: Input,
},
],
},
{
id: "featureSelectionPage",
elements: [
{
id: "featureSelection",
type: "radio",
label: "Select the Best Practices you need:",
name: "featureSelection",
options: [
{
label: "Onboarding Segmentation",
value: "onboardingSegmentation",
frontend: {
icon: OnboardingIcon,
description:
"Get to know your users right from the start. Ask a few questions early, let us enrich the profile.",
},
},
{
label: "Superhuman PMF Engine",
value: "pmf",
frontend: {
icon: PMFIcon,
description:
"Find out how disappointed people would be if they could not use your service any more.",
},
},
{
label: "Feature Chaser",
value: "featureChaser",
frontend: {
icon: DogChaserIcon,
description:
"Show a survey about a new feature shown only to people who used it.",
},
},
{
label: "Cancel Subscription Flow",
value: "cancelSubscriptionFlow",
frontend: {
icon: CancelSubscriptionIcon,
description:
"Request users going through a cancel subscription flow before cancelling.",
},
},
{
label: "Interview Prompt",
value: "interviewPrompt",
frontend: {
icon: InterviewPromptIcon,
description:
"Ask high-interest users to book a time in your calendar to get all the juicy details.",
},
},
{
label: "Fake Door Follow-Up",
value: "fakeDoorFollowUp",
frontend: {
icon: DoorIcon,
description:
"Running a fake door experiment? Catch users right when they are full of expectations.",
},
},
{
label: "FeedbackBox",
value: "feedbackBox",
frontend: {
icon: FeedbackIcon,
description: "Give users the chance to share feedback in a single click.",
},
},
{
label: "Bug Report Form",
value: "bugReportForm",
frontend: {
icon: BugBlueIcon,
description: "Catch all bugs in your SaaS with easy and accessible bug reports.",
},
},
{
label: "Rage Click Survey",
value: "rageClickSurvey",
frontend: {
icon: AngryBirdRageIcon,
description:
"Sometimes things dont work. Trigger this rage click survey to catch users in rage.",
},
},
{
label: "Feature Request Widget",
value: "featureRequestWidget",
frontend: {
icon: FeatureRequestIcon,
description:
"Allow users to request features and pipe it to GitHub projects or Linear.",
},
},
],
component: FeatureSelection,
},
],
},
{
id: "wauPage",
config: {
autoSubmit: true,
},
elements: [
{
id: "wau",
type: "radio",
label: "How many weekly active users do you have?",
name: "wau",
options: [
{ label: "Not launched", value: "notLaunched", frontend: { icon: CrossMarkIcon } },
{ label: "10-100", value: "10-100", frontend: { icon: UserCoupleIcon } },
{ label: "100-1.000", value: "100-1000", frontend: { icon: UserGroupIcon } },
{ label: "1.000+", value: "10000+", frontend: { icon: UserGroupIcon } },
],
component: IconRadio,
},
],
},
{
id: "goalPage",
config: {
autoSubmit: true,
},
elements: [
{
id: "goal",
type: "radio",
label: "Want to become a beta user?",
help: "Answer 3 open-ended questions and get 50% off in the first year.",
name: "goal",
options: [
{
label: "No, just notify me on launch",
value: "justNotify",
frontend: { icon: BellIcon },
},
{
label: "Yes, take the survey",
value: "becomeBetaUser",
frontend: { icon: UserCommentIcon },
},
],
component: IconRadio,
},
],
branchingRules: [
{
type: "value",
name: "goal",
value: "justNotify",
nextPageId: "thankYouPageNotify",
},
],
},
{
id: "namePage",
elements: [
{
id: "name",
type: "text",
label: "First of all, whats your name?",
name: "name",
frontend: { placeholder: "First name" },
component: Input,
},
],
},
{
id: "urgencyPage",
config: {
autoSubmit: true,
},
elements: [
{
id: "urgency",
type: "radio",
label: "How urgently do you need this?",
name: "urgency",
options: [
{ label: "1", value: "1" },
{ label: "2", value: "2" },
{ label: "3", value: "3" },
{ label: "4", value: "4" },
{ label: "5", value: "5" },
{ label: "6", value: "6" },
{ label: "7", value: "7" },
{ label: "8", value: "8" },
{ label: "9", value: "9" },
{ label: "10", value: "10" },
],
frontend: {
min: 1,
max: 10,
minLabel: "Im just curious",
maxLabel: "As soon as possible",
},
],
component: IconRadio,
},
],
branchingRules: [
{
type: "value",
name: "pmf",
value: "no",
nextPageId: "pmfApproachPage",
},
],
},
{
id: "scalingResearchPage",
elements: [
{
id: "scalingResearch",
type: "text",
label: "What is your approach for scaling user research?",
name: "scalingResearch",
frontend: { required: true, placeholder: "Last time, I..." },
component: Textarea,
},
],
},
{
id: "userResearchHardestPartPage",
config: {
allowSkip: true,
component: Scale,
},
],
},
elements: [
{
id: "userResearchHardestPart",
type: "text",
label: "What is the hardest part about it?",
name: "userResearchHardestPart",
frontend: { required: false, placeholder: "Please tell us about your challenges." },
component: Textarea,
{
id: "pmfPage",
config: {
autoSubmit: true,
},
],
},
{
id: "toolsMaintainPmfPage",
elements: [
{
id: "toolsMaintainPmf",
type: "text",
label: "What tools help you maintain Product-Market Fit?",
name: "toolsMaintainPmf",
frontend: { required: true, placehodler: "Mixpanel, Segment, Intercom..." },
component: Textarea,
},
],
branchingRules: [
{
type: "value",
name: "pmf",
value: "yes",
nextPageId: "thankYouPageBetaUser",
},
],
},
{
id: "pmfApproachPage",
elements: [
{
id: "pmfApproach",
type: "text",
label: "What is your approach for finding Product-Market Fit?",
name: "pmfApproach",
frontend: { placeholder: "Last time, I..." },
component: Textarea,
},
],
},
{
id: "pmfHardestPartPage",
config: {
allowSkip: true,
elements: [
{
id: "pmf",
type: "radio",
label: "Have you found Product-Market-Fit?",
name: "pmf",
options: [
{
label: "Yes",
value: "yes",
frontend: { icon: CheckMarkIcon },
},
{
label: "No",
value: "no",
frontend: { icon: CrossMarkIcon },
},
],
component: IconRadio,
},
],
branchingRules: [
{
type: "value",
name: "pmf",
value: "no",
nextPageId: "pmfApproachPage",
},
],
},
elements: [
{
id: "pmfHardestPart",
type: "text",
label: "What is the hardest part about it?",
name: "pmfHardestPart",
frontend: { placeholder: "Please tell us about your challenges." },
component: Textarea,
{
id: "scalingResearchPage",
elements: [
{
id: "scalingResearch",
type: "text",
label: "What is your approach for scaling user research?",
name: "scalingResearch",
frontend: { required: true, placeholder: "Last time, I..." },
component: Textarea,
},
],
},
{
id: "userResearchHardestPartPage",
config: {
allowSkip: true,
},
],
},
{
id: "pmfFindingToolsPage",
elements: [
{
id: "pmfFindingTools",
type: "text",
label: "What tools help you finding Product-Market Fit?",
name: "pmfFindingTools",
frontend: { placeholder: "Mixpanel, Segment, Intercom..." },
component: Textarea,
elements: [
{
id: "userResearchHardestPart",
type: "text",
label: "What is the hardest part about it?",
name: "userResearchHardestPart",
frontend: { required: false, placeholder: "Please tell us about your challenges." },
component: Textarea,
},
],
},
{
id: "toolsMaintainPmfPage",
elements: [
{
id: "toolsMaintainPmf",
type: "text",
label: "What tools help you maintain Product-Market Fit?",
name: "toolsMaintainPmf",
frontend: { required: true, placehodler: "Mixpanel, Segment, Intercom..." },
component: Textarea,
},
],
branchingRules: [
{
type: "value",
name: "pmf",
value: "yes",
nextPageId: "thankYouPageBetaUser",
},
],
},
{
id: "pmfApproachPage",
elements: [
{
id: "pmfApproach",
type: "text",
label: "What is your approach for finding Product-Market Fit?",
name: "pmfApproach",
frontend: { placeholder: "Last time, I..." },
component: Textarea,
},
],
},
{
id: "pmfHardestPartPage",
config: {
allowSkip: true,
},
],
branchingRules: [
{
type: "value",
name: "pmf",
value: "no",
nextPageId: "thankYouPageBetaUser",
},
],
},
{
id: "thankYouPageNotify",
endScreen: true,
elements: [
{
id: "thankYouNotify",
type: "html",
component: ThankYouHeading,
},
],
},
{
id: "thankYouPageBetaUser",
endScreen: true,
elements: [
{
id: "thankYouBetaUser",
type: "html",
component: ThankYouHeading,
},
{
id: "thankYouBetaUser",
type: "html",
component: ThankYouPlans,
},
],
},
],
}}
/>
elements: [
{
id: "pmfHardestPart",
type: "text",
label: "What is the hardest part about it?",
name: "pmfHardestPart",
frontend: { placeholder: "Please tell us about your challenges." },
component: Textarea,
},
],
},
{
id: "pmfFindingToolsPage",
elements: [
{
id: "pmfFindingTools",
type: "text",
label: "What tools help you finding Product-Market Fit?",
name: "pmfFindingTools",
frontend: { placeholder: "Mixpanel, Segment, Intercom..." },
component: Textarea,
},
],
branchingRules: [
{
type: "value",
name: "pmf",
value: "no",
nextPageId: "thankYouPageBetaUser",
},
],
},
{
id: "thankYouPageNotify",
endScreen: true,
elements: [
{
id: "thankYouNotify",
type: "html",
component: ThankYouHeading,
},
],
},
{
id: "thankYouPageBetaUser",
endScreen: true,
elements: [
{
id: "thankYouBetaUser",
type: "html",
component: ThankYouHeading,
},
{
id: "thankYouBetaUser",
type: "html",
component: ThankYouPlans,
},
],
},
],
}}
/>
</div>
</div>
</div>
</LayoutWaitlist>
);
</LayoutWaitlist>
);
};
export default WaitlistPage;

View File

@@ -0,0 +1,60 @@
{
"name": "@formbricks/engine-react",
"version": "0.1.0",
"author": "Formbricks <hola@formbricks.com>",
"description": "Headless Form Engine for Formbricks",
"homepage": "https://formbricks.com",
"main": "./dist/index.js",
"module": "dist/index.mjs",
"types": "./dist/index.d.ts",
"sideEffects": false,
"exports": {
"./package.json": "./package.json",
"./styles.css": "./dist/styles.css",
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"license": "MIT",
"scripts": {
"build": "tsup --dts",
"dev": "tsup --dts --external react --watch",
"clean": "rm -rf dist"
},
"devDependencies": {
"@formbricks/tsconfig": "workspace:*",
"@types/react": "^18.0.25",
"@types/react-dom": "^18.0.8",
"concurrently": "^7.5.0",
"eslint": "^8.27.0",
"eslint-config-formbricks": "workspace:*",
"react": "^18.2.0",
"tsup": "^6.4.0",
"typescript": "^4.8.4"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18"
},
"dependencies": {
"react-hook-form": "^7.39.1"
},
"publishConfig": {
"access": "public"
},
"keywords": [
"formbricks",
"react",
"form",
"forms",
"typescript",
"survey",
"surveys",
"engine"
],
"repository": {
"type": "git",
"url": "https://github.com/formbricks/formbricks"
}
}

View File

@@ -0,0 +1,148 @@
import clsx from "clsx";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { FormPage } from "../types";
interface FormProps {
page: FormPage;
onSkip: () => void;
onPageSubmit: (submission: any) => void;
onFinished: ({ submission }: any) => void;
submission: any;
setSubmission: (v: any) => void;
finished: boolean;
formbricksUrl: string;
formId: string;
schema: any;
}
export function EnginePage({
page,
onSkip,
onPageSubmit,
onFinished,
submission,
setSubmission,
formbricksUrl,
formId,
schema,
}: FormProps) {
const [submissionId, setSubmissionId] = useState<string>();
const {
handleSubmit,
control,
register,
reset,
formState: {},
} = useForm();
const [submittingPage, setSubmittingPage] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
reset();
}, [page, reset]);
useEffect(() => {
if (page.endScreen) {
fetch(`${formbricksUrl}/api/capture/forms/${formId}/submissions/${submissionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ finished: true }),
});
onFinished({ submission });
}
}, [page, formId, formbricksUrl, submissionId]);
const sendToFormbricks = async (partialSubmission: any) => {
const submissionBody: any = { data: partialSubmission };
if (page.config?.addFieldsToCustomer && Array.isArray(page.config?.addFieldsToCustomer)) {
for (const field of page.config?.addFieldsToCustomer) {
if (field in partialSubmission) {
if (!("customer" in submissionBody)) {
submissionBody.customer = {};
}
submissionBody.customer[field] = partialSubmission[field];
}
}
}
if (!submissionId) {
const res = await Promise.all([
fetch(`${formbricksUrl}/api/capture/forms/${formId}/submissions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(submissionBody),
}),
fetch(`${formbricksUrl}/api/capture/forms/${formId}/schema`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(schema),
}),
]);
if (!res[0].ok || !res[1].ok) {
alert("There was an error sending this form. Please contact us at hola@formbricks.com");
return;
}
const submission = await res[0].json();
setSubmissionId(submission.id);
} else {
const res = await fetch(`${formbricksUrl}/api/capture/forms/${formId}/submissions/${submissionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(submissionBody),
});
if (!res.ok) {
alert("There was an error sending this form. Please contact us at hola@formbricks.com");
return;
}
}
};
const submitPage = async (data: any) => {
setSubmittingPage(true);
const updatedSubmission = { ...submission, ...data };
setSubmission(updatedSubmission);
try {
await sendToFormbricks(data);
setSubmittingPage(false);
onPageSubmit({ submission: updatedSubmission, page, pageSubmission: data });
window.scrollTo(0, 0);
} catch (e) {
console.error(e);
alert("There was an error sending this form. Please contact us at hola@formbricks.com");
}
};
const handleSubmitElement = () => {
if (page.config?.autoSubmit && page.elements.length == 1) {
formRef.current?.requestSubmit();
setSubmittingPage(true);
}
};
return (
<form onSubmit={handleSubmit(submitPage)} ref={formRef}>
{page.elements.map((element) => {
const ElementComponent = element.component;
return (
<div key={element.id}>
{element.name ? (
<ElementComponent
element={element}
control={control}
register={register}
onSubmit={() => handleSubmitElement()}
disabled={submittingPage}
allowSkip={page.config?.allowSkip}
skipAction={onSkip}
autoSubmit={page.config?.autoSubmit}
loading={submittingPage}
/>
) : (
<ElementComponent element={element} />
)}
</div>
);
})}
</form>
);
}

View File

@@ -0,0 +1,106 @@
import { useEffect, useMemo, useState } from "react";
import { Form } from "../types";
import { EnginePage } from "./EnginePage";
interface FormProps {
schema: Form;
formbricksUrl: string;
formId: string;
onFinished?: ({ submission }: any) => void;
onPageSubmit?: ({ page }: any) => void;
}
export function FormbricksEngine({
schema,
formbricksUrl,
formId,
onFinished = () => {},
onPageSubmit = () => {},
}: FormProps) {
if (!schema) {
console.error("Formbricks Engine: No form provided");
return null;
}
const [currentPage, setCurrentPage] = useState(schema.pages[0]);
const [submission, setSubmission] = useState<any>({});
const [finished, setFinished] = useState(false);
const cleanedSchema = useMemo(() => generateSchema(schema), [schema]);
useEffect(() => {
// warmup request
fetch(`${formbricksUrl}/api/capture/forms/${formId}/submissions`, {
method: "OPTIONS",
});
}, []);
const navigateToNextPage = (currentSubmission: any) => {
const nextPage = calculateNextPage(schema, currentSubmission);
setCurrentPage(nextPage);
if (nextPage.endScreen) {
setFinished(true);
}
};
const calculateNextPage = (Form: Form, submission: any) => {
if (currentPage.branchingRules) {
for (const rule of currentPage.branchingRules) {
if (rule.type === "value") {
if (rule.value === submission[rule.name]) {
const nextPage = Form.pages.find((p) => p.id === rule.nextPageId);
if (!nextPage) {
throw new Error(`Next page ${rule.nextPageId} not found`);
}
return nextPage;
}
}
}
}
const currentPageIdx = Form.pages.findIndex((p) => p.id === currentPage.id);
return Form.pages[currentPageIdx + 1];
};
return (
<div>
<EnginePage
page={currentPage}
onSkip={() => navigateToNextPage(submission)}
onPageSubmit={({ submission, page, pageSubmission }: any) => {
navigateToNextPage(submission);
onPageSubmit({ submission, page, pageSubmission });
}}
onFinished={onFinished}
submission={submission}
setSubmission={setSubmission}
finished={finished}
formbricksUrl={formbricksUrl}
formId={formId}
schema={cleanedSchema}
/>
</div>
);
}
function generateSchema(Form: Form) {
const schema: any = JSON.parse(JSON.stringify(Form));
deleteProps(schema, "frontend");
return schema;
}
function deleteProps(obj: any, propName: string) {
if (Array.isArray(obj)) {
for (let v of obj) {
if (v instanceof Object) {
deleteProps(v, propName);
}
}
return;
}
delete obj[propName];
for (let v of Object.values(obj)) {
if (v instanceof Object) {
deleteProps(v, propName);
}
}
}

View File

@@ -0,0 +1 @@
export * from "./components/FormbricksEngine";

View File

@@ -0,0 +1,41 @@
import { useContext, useEffect } from "react";
import { SchemaContext } from "../components/FormbricksEngine";
export const getOptionsSchema = (options: any[] | undefined) => {
const newOptions = [];
if (options) {
for (const option of options) {
if (typeof option === "string") {
newOptions.push({ label: option, value: option });
}
if (typeof option === "object" && "value" in option && "label" in option) {
newOptions.push({ label: option.label, value: option.value });
}
}
}
return newOptions;
};
export const useSchema = () => {
const { schema } = useContext(SchemaContext);
return schema;
};
export const useEffectUpdateSchema = (props: any, type: string) => {
const { setSchema } = useContext(SchemaContext);
useEffect(() => {
setSchema((schema: any) => {
const newSchema = JSON.parse(JSON.stringify(schema));
let elementIdx = newSchema.pages[0].elements.findIndex((e: any) => e.name === props.name);
if (elementIdx === -1) {
newSchema.pages[0].elements.push({ ...props, type });
elementIdx = newSchema.pages[0].elements.length - 1; // set elementIdx to newly added elem
}
if ("options" in props) {
newSchema.pages[0].elements[elementIdx].options = getOptionsSchema(props.options);
}
return newSchema;
});
}, [props, setSchema]);
};

View File

@@ -0,0 +1,40 @@
export interface FormOption {
label: string;
value: string;
frontend?: any;
}
export interface FormPage {
id: string;
endScreen?: boolean;
elements: FormElement[];
config?: {
addFieldsToCustomer?: string[];
autoSubmit?: boolean;
allowSkip?: boolean;
};
branchingRules?: {
type: "value";
name: string;
value: string;
nextPageId: string;
}[];
}
export interface FormElement {
id: string;
name?: string;
label?: string;
help?: string;
type: "radio" | "text" | "checkbox" | "html";
options?: FormOption[];
component: React.FC<any>;
frontend?: any;
}
export interface Form {
pages: FormPage[];
config?: {
progressBar?: boolean;
};
}

View File

@@ -0,0 +1,5 @@
{
"extends": "@formbricks/tsconfig/react-library.json",
"include": ["."],
"exclude": ["dist", "build", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
import { defineConfig } from "tsup";
const isProduction = process.env.NODE_ENV === "production";
export default defineConfig({
format: ["cjs", "esm"],
entry: ["src/index.tsx"],
clean: isProduction,
splitting: true,
dts: true,
minify: isProduction,
});

View File

@@ -20,7 +20,7 @@
"license": "MIT",
"scripts": {
"build": "tsup --dts && tailwindcss -i ./src/styles.css -o ./dist/styles.css --minify",
"dev": "concurrently \"tsup --dts --external react --watch && generate-tailwind\" \"tailwindcss -i ./src/styles.css -o ./dist/styles.css --watch\"",
"dev": "concurrently \"tsup --dts --external react --watch\" \"tailwindcss -i ./src/styles.css -o ./dist/styles.css --watch\"",
"clean": "rm -rf dist"
},
"devDependencies": {

66
pnpm-lock.yaml generated
View File

@@ -66,6 +66,7 @@ importers:
apps/formbricks-com:
specifiers:
'@docsearch/react': ^3.3.2
'@formbricks/engine-react': workspace:*
'@formbricks/pmf': workspace:*
'@formbricks/react': workspace:*
'@formbricks/ui': workspace:*
@@ -101,6 +102,7 @@ importers:
typescript: 4.9.4
dependencies:
'@docsearch/react': 3.3.2_hqhntm65ctxq3v64bjwdr5bfcq
'@formbricks/engine-react': link:../../packages/engine-react
'@formbricks/pmf': link:../../packages/pmfWidget
'@formbricks/react': link:../../packages/react
'@formbricks/ui': link:../../packages/ui
@@ -339,6 +341,33 @@ importers:
react: 18.2.0
typescript: 4.9.4
packages/engine-react:
specifiers:
'@formbricks/tsconfig': workspace:*
'@types/react': ^18.0.25
'@types/react-dom': ^18.0.8
concurrently: ^7.5.0
eslint: ^8.27.0
eslint-config-formbricks: workspace:*
react: ^18.2.0
react-hook-form: ^7.39.1
tailwindcss: ^3.2.2
tsup: ^6.4.0
typescript: ^4.8.4
dependencies:
react-hook-form: 7.43.0_react@18.2.0
devDependencies:
'@formbricks/tsconfig': link:../tsconfig
'@types/react': 18.0.27
'@types/react-dom': 18.0.10
concurrently: 7.6.0
eslint: 8.33.0
eslint-config-formbricks: link:../eslint-config-formbricks
react: 18.2.0
tailwindcss: 3.2.4_postcss@8.4.21
tsup: 6.5.0_uujdqti2krmttzhqvubwnsmcci
typescript: 4.9.5
packages/eslint-config-formbricks:
specifiers:
eslint: ^8.26.0
@@ -18846,6 +18875,43 @@ packages:
- ts-node
dev: true
/tsup/6.5.0_uujdqti2krmttzhqvubwnsmcci:
resolution: {integrity: sha512-36u82r7rYqRHFkD15R20Cd4ercPkbYmuvRkz3Q1LCm5BsiFNUgpo36zbjVhCOgvjyxNBWNKHsaD5Rl8SykfzNA==}
engines: {node: '>=14'}
hasBin: true
peerDependencies:
'@swc/core': ^1
postcss: ^8.4.12
typescript: ^4.1.0
peerDependenciesMeta:
'@swc/core':
optional: true
postcss:
optional: true
typescript:
optional: true
dependencies:
bundle-require: 3.1.2_esbuild@0.15.16
cac: 6.7.14
chokidar: 3.5.3
debug: 4.3.4
esbuild: 0.15.16
execa: 5.1.1
globby: 11.1.0
joycon: 3.1.1
postcss: 8.4.21
postcss-load-config: 3.1.4_postcss@8.4.21
resolve-from: 5.0.0
rollup: 3.5.1
source-map: 0.8.0-beta.0
sucrase: 3.29.0
tree-kill: 1.2.2
typescript: 4.9.5
transitivePeerDependencies:
- supports-color
- ts-node
dev: true
/tsutils/3.21.0_typescript@4.9.3:
resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==}
engines: {node: '>= 6'}