From cd500a2e997f023647185a478b7430f935b62af3 Mon Sep 17 00:00:00 2001 From: Matthias Nannt Date: Mon, 7 Aug 2023 13:57:05 +0200 Subject: [PATCH 01/40] add vite survey package --- package.json | 2 +- packages/surveys/.gitignore | 24 + packages/surveys/index.html | 13 + packages/surveys/package.json | 21 + packages/surveys/src/app.css | 25 + packages/surveys/src/app.tsx | 33 ++ .../surveys/src/components/BackButton.tsx | 20 + .../surveys/src/components/CTAQuestion.tsx | 68 +++ .../src/components/ConsentQuestion.tsx | 101 ++++ .../src/components/FormbricksSignature.tsx | 17 + packages/surveys/src/components/Headline.tsx | 20 + packages/surveys/src/components/HtmlBody.tsx | 11 + packages/surveys/src/components/Modal.tsx | 105 ++++ .../MultipleChoiceMultiQuestion.tsx | 210 ++++++++ .../MultipleChoiceSingleQuestion.tsx | 175 +++++++ .../surveys/src/components/NPSQuestion.tsx | 123 +++++ .../src/components/OpenTextQuestion.tsx | 98 ++++ packages/surveys/src/components/Progress.tsx | 11 + .../src/components/QuestionConditional.tsx | 102 ++++ .../surveys/src/components/RatingQuestion.tsx | 219 ++++++++ packages/surveys/src/components/Smileys.tsx | 473 ++++++++++++++++++ packages/surveys/src/components/Subheader.tsx | 9 + .../surveys/src/components/SubmitButton.tsx | 20 + .../surveys/src/components/SurveyView.tsx | 277 ++++++++++ .../surveys/src/components/ThankYouCard.tsx | 38 ++ packages/surveys/src/index.css | 69 +++ packages/surveys/src/lib/actions.ts | 66 +++ packages/surveys/src/lib/api.ts | 16 + packages/surveys/src/lib/automaticActions.ts | 40 ++ packages/surveys/src/lib/cleanHtml.ts | 79 +++ packages/surveys/src/lib/commandQueue.ts | 61 +++ packages/surveys/src/lib/config.ts | 47 ++ packages/surveys/src/lib/display.ts | 54 ++ packages/surveys/src/lib/errors.ts | 133 +++++ packages/surveys/src/lib/init.ts | 150 ++++++ packages/surveys/src/lib/localStorage.ts | 24 + packages/surveys/src/lib/logger.ts | 47 ++ packages/surveys/src/lib/noCodeEvents.ts | 148 ++++++ packages/surveys/src/lib/person.ts | 192 +++++++ packages/surveys/src/lib/response.ts | 58 +++ packages/surveys/src/lib/session.ts | 6 + packages/surveys/src/lib/styles.ts | 12 + packages/surveys/src/lib/sync.ts | 52 ++ packages/surveys/src/lib/utils.ts | 44 ++ packages/surveys/src/lib/widget.ts | 51 ++ packages/surveys/src/main.tsx | 5 + packages/surveys/src/test1.ts | 155 ++++++ packages/surveys/src/test2.html | 13 + packages/surveys/src/test3.ts | 15 + packages/surveys/src/test4.html | 13 + packages/surveys/src/vite-env.d.ts | 1 + packages/surveys/tsconfig.json | 26 + packages/surveys/tsconfig.node.json | 10 + packages/surveys/vite.config.ts | 17 + pnpm-lock.yaml | 200 ++++++-- 55 files changed, 3990 insertions(+), 29 deletions(-) create mode 100644 packages/surveys/.gitignore create mode 100644 packages/surveys/index.html create mode 100644 packages/surveys/package.json create mode 100644 packages/surveys/src/app.css create mode 100644 packages/surveys/src/app.tsx create mode 100644 packages/surveys/src/components/BackButton.tsx create mode 100644 packages/surveys/src/components/CTAQuestion.tsx create mode 100644 packages/surveys/src/components/ConsentQuestion.tsx create mode 100644 packages/surveys/src/components/FormbricksSignature.tsx create mode 100644 packages/surveys/src/components/Headline.tsx create mode 100644 packages/surveys/src/components/HtmlBody.tsx create mode 100644 packages/surveys/src/components/Modal.tsx create mode 100644 packages/surveys/src/components/MultipleChoiceMultiQuestion.tsx create mode 100644 packages/surveys/src/components/MultipleChoiceSingleQuestion.tsx create mode 100644 packages/surveys/src/components/NPSQuestion.tsx create mode 100644 packages/surveys/src/components/OpenTextQuestion.tsx create mode 100644 packages/surveys/src/components/Progress.tsx create mode 100644 packages/surveys/src/components/QuestionConditional.tsx create mode 100644 packages/surveys/src/components/RatingQuestion.tsx create mode 100644 packages/surveys/src/components/Smileys.tsx create mode 100644 packages/surveys/src/components/Subheader.tsx create mode 100644 packages/surveys/src/components/SubmitButton.tsx create mode 100644 packages/surveys/src/components/SurveyView.tsx create mode 100644 packages/surveys/src/components/ThankYouCard.tsx create mode 100644 packages/surveys/src/index.css create mode 100644 packages/surveys/src/lib/actions.ts create mode 100644 packages/surveys/src/lib/api.ts create mode 100644 packages/surveys/src/lib/automaticActions.ts create mode 100644 packages/surveys/src/lib/cleanHtml.ts create mode 100644 packages/surveys/src/lib/commandQueue.ts create mode 100644 packages/surveys/src/lib/config.ts create mode 100644 packages/surveys/src/lib/display.ts create mode 100644 packages/surveys/src/lib/errors.ts create mode 100644 packages/surveys/src/lib/init.ts create mode 100644 packages/surveys/src/lib/localStorage.ts create mode 100644 packages/surveys/src/lib/logger.ts create mode 100644 packages/surveys/src/lib/noCodeEvents.ts create mode 100644 packages/surveys/src/lib/person.ts create mode 100644 packages/surveys/src/lib/response.ts create mode 100644 packages/surveys/src/lib/session.ts create mode 100644 packages/surveys/src/lib/styles.ts create mode 100644 packages/surveys/src/lib/sync.ts create mode 100644 packages/surveys/src/lib/utils.ts create mode 100644 packages/surveys/src/lib/widget.ts create mode 100644 packages/surveys/src/main.tsx create mode 100644 packages/surveys/src/test1.ts create mode 100644 packages/surveys/src/test2.html create mode 100644 packages/surveys/src/test3.ts create mode 100644 packages/surveys/src/test4.html create mode 100644 packages/surveys/src/vite-env.d.ts create mode 100644 packages/surveys/tsconfig.json create mode 100644 packages/surveys/tsconfig.node.json create mode 100644 packages/surveys/vite.config.ts diff --git a/package.json b/package.json index bce02012194..95e93effea8 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "db:migrate:deploy": "turbo run db:migrate:deploy", "db:migrate:vercel": "turbo run db:migrate:vercel", "db:push": "turbo run db:push", - "go": "turbo run go --concurrency 16", + "go": "turbo run go --concurrency 17", "dev": "turbo run dev --parallel", "start": "turbo run start --parallel", "format": "prettier --write \"**/*.{ts,tsx,md}\"", diff --git a/packages/surveys/.gitignore b/packages/surveys/.gitignore new file mode 100644 index 00000000000..a547bf36d8d --- /dev/null +++ b/packages/surveys/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/surveys/index.html b/packages/surveys/index.html new file mode 100644 index 00000000000..9a0d18831ce --- /dev/null +++ b/packages/surveys/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + Preact + TS + + + + + + diff --git a/packages/surveys/package.json b/packages/surveys/package.json new file mode 100644 index 00000000000..5d9db9c671d --- /dev/null +++ b/packages/surveys/package.json @@ -0,0 +1,21 @@ +{ + "name": "surveys", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "preact": "^10.16.0", + "preact-custom-element": "^4.2.1" + }, + "devDependencies": { + "@preact/preset-vite": "^2.5.0", + "@types/preact-custom-element": "^4.0.1", + "typescript": "^5.0.2", + "vite": "^4.4.5" + } +} diff --git a/packages/surveys/src/app.css b/packages/surveys/src/app.css new file mode 100644 index 00000000000..088ed3ace55 --- /dev/null +++ b/packages/surveys/src/app.css @@ -0,0 +1,25 @@ +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.preact:hover { + filter: drop-shadow(0 0 2em #673ab8aa); +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/packages/surveys/src/app.tsx b/packages/surveys/src/app.tsx new file mode 100644 index 00000000000..b1f47a1d03f --- /dev/null +++ b/packages/surveys/src/app.tsx @@ -0,0 +1,33 @@ +import { useState } from 'preact/hooks' +import preactLogo from './assets/preact.svg' +import viteLogo from '/vite.svg' +import './app.css' + +export function App() { + const [count, setCount] = useState(0) + + return ( + <> +
+ + + + + + +
+

Vite + Preact

+
+ +

+ Edit src/app.tsx and save to test HMR +

+
+

+ Click on the Vite and Preact logos to learn more +

+ + ) +} diff --git a/packages/surveys/src/components/BackButton.tsx b/packages/surveys/src/components/BackButton.tsx new file mode 100644 index 00000000000..7d23350b7f5 --- /dev/null +++ b/packages/surveys/src/components/BackButton.tsx @@ -0,0 +1,20 @@ +import { h } from "preact"; + +import { cn } from "@/../../packages/lib/cn"; + +interface BackButtonProps { + onClick: () => void; +} + +export function BackButton({ onClick }: BackButtonProps) { + return ( + + ); +} diff --git a/packages/surveys/src/components/CTAQuestion.tsx b/packages/surveys/src/components/CTAQuestion.tsx new file mode 100644 index 00000000000..936872d6463 --- /dev/null +++ b/packages/surveys/src/components/CTAQuestion.tsx @@ -0,0 +1,68 @@ +import { h } from "preact"; +import { TResponseData } from "../../../types/v1/responses"; +import type { TSurveyCTAQuestion } from "../../../types/v1/surveys"; +import Headline from "./Headline"; +import HtmlBody from "./HtmlBody"; +import SubmitButton from "./SubmitButton"; +import { BackButton } from "./BackButton"; + +interface CTAQuestionProps { + question: TSurveyCTAQuestion; + onSubmit: (data: TResponseData) => void; + lastQuestion: boolean; + brandColor: string; + storedResponseValue: number | null; + goToNextQuestion: (answer: TResponseData) => void; + goToPreviousQuestion?: (answer?: TResponseData) => void; +} + +export default function CTAQuestion({ + question, + onSubmit, + lastQuestion, + brandColor, + storedResponseValue, + goToNextQuestion, + goToPreviousQuestion, +}: CTAQuestionProps) { + return ( +
+ + + +
+ {goToPreviousQuestion && goToPreviousQuestion()} />} +
+ {(!question.required || storedResponseValue) && ( + + )} + { + if (question.buttonExternal && question.buttonUrl) { + window?.open(question.buttonUrl, "_blank")?.focus(); + } + onSubmit({ [question.id]: "clicked" }); + }} + type="button" + /> +
+
+
+ ); +} diff --git a/packages/surveys/src/components/ConsentQuestion.tsx b/packages/surveys/src/components/ConsentQuestion.tsx new file mode 100644 index 00000000000..565a8e62b92 --- /dev/null +++ b/packages/surveys/src/components/ConsentQuestion.tsx @@ -0,0 +1,101 @@ +import { h } from "preact"; +import { TResponseData } from "../../../types/v1/responses"; +import type { TSurveyConsentQuestion } from "../../../types/v1/surveys"; +import Headline from "./Headline"; +import HtmlBody from "./HtmlBody"; +import SubmitButton from "./SubmitButton"; +import { useEffect, useState } from "preact/hooks"; +import { BackButton } from "./BackButton"; + +interface ConsentQuestionProps { + question: TSurveyConsentQuestion; + onSubmit: (data: TResponseData) => void; + lastQuestion: boolean; + brandColor: string; + storedResponseValue: string | null; + goToNextQuestion: (answer: TResponseData) => void; + goToPreviousQuestion?: (answer?: TResponseData) => void; +} + +export default function ConsentQuestion({ + question, + onSubmit, + lastQuestion, + brandColor, + storedResponseValue, + goToNextQuestion, + goToPreviousQuestion, +}: ConsentQuestionProps) { + const [answer, setAnswer] = useState("dismissed"); + + useEffect(() => { + setAnswer(storedResponseValue ?? "dismissed"); + }, [storedResponseValue, question]); + + const handleOnChange = () => { + answer === "accepted" ? setAnswer("dissmissed") : setAnswer("accepted"); + }; + + const handleSumbit = (value: string) => { + const data = { + [question.id]: value, + }; + if (storedResponseValue === value) { + goToNextQuestion(data); + setAnswer("dismissed"); + + return; + } + onSubmit(data); + setAnswer("dismissed"); + }; + return ( +
+ + + +
{ + e.preventDefault(); + handleSumbit(answer); + }}> + + +
+ {goToPreviousQuestion && ( + + goToPreviousQuestion({ + [question.id]: answer, + }) + } + /> + )} +
+ {}} + /> +
+ +
+ ); +} diff --git a/packages/surveys/src/components/FormbricksSignature.tsx b/packages/surveys/src/components/FormbricksSignature.tsx new file mode 100644 index 00000000000..df3ed58ce26 --- /dev/null +++ b/packages/surveys/src/components/FormbricksSignature.tsx @@ -0,0 +1,17 @@ +import { h } from "preact"; + +export default function FormbricksSignature() { + return ( + +

+ Powered by{" "} + + Formbricks + +

+
+ ); +} diff --git a/packages/surveys/src/components/Headline.tsx b/packages/surveys/src/components/Headline.tsx new file mode 100644 index 00000000000..0ef72f31051 --- /dev/null +++ b/packages/surveys/src/components/Headline.tsx @@ -0,0 +1,20 @@ +import { h } from "preact"; + +export default function Headline({ + headline, + questionId, + style, +}: { + headline: string; + questionId: string; + style?: any; +}) { + return ( + + ); +} diff --git a/packages/surveys/src/components/HtmlBody.tsx b/packages/surveys/src/components/HtmlBody.tsx new file mode 100644 index 00000000000..f49c58784f3 --- /dev/null +++ b/packages/surveys/src/components/HtmlBody.tsx @@ -0,0 +1,11 @@ +import { h } from "preact"; +import { cleanHtml } from "../lib/cleanHtml"; + +export default function HtmlBody({ htmlString, questionId }: { htmlString?: string; questionId: string }) { + return ( + + ); +} diff --git a/packages/surveys/src/components/Modal.tsx b/packages/surveys/src/components/Modal.tsx new file mode 100644 index 00000000000..7d092e21412 --- /dev/null +++ b/packages/surveys/src/components/Modal.tsx @@ -0,0 +1,105 @@ +import type { PlacementType } from "@formbricks/types/js"; +import { h, VNode } from "preact"; +import { useEffect, useRef, useState } from "preact/hooks"; +import { cn } from "../lib/utils"; + +export default function Modal({ + children, + isOpen, + placement, + clickOutside, + darkOverlay, + close, +}: { + children: VNode; + isOpen: boolean; + placement: PlacementType; + clickOutside: boolean; + darkOverlay: boolean; + close: () => void; +}) { + const [show, setShow] = useState(false); + const isCenter = placement === "center"; + const modalRef = useRef(null); + + useEffect(() => { + setShow(isOpen); + }, [isOpen]); + + useEffect(() => { + if (!isCenter) return; + + function handleClickOutside(event) { + if (clickOutside && show && modalRef.current && !modalRef.current.contains(event.target)) { + close(); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [show, clickOutside, close, isCenter]); + + const getPlacementStyle = (placement: PlacementType) => { + switch (placement) { + case "bottomRight": + return "fb-bottom-3 sm:fb-right-3"; + case "topRight": + return "sm:fb-top-3 sm:fb-right-3 fb-bottom-3"; + case "topLeft": + return "sm:fb-top-3 sm:fb-left-3 fb-bottom-3"; + case "bottomLeft": + return "fb-bottom-3 sm:fb-left-3"; + case "center": + return "fb-top-1/2 fb-left-1/2 fb-transform -fb-translate-x-1/2 -fb-translate-y-1/2"; + default: + return "fb-bottom-3 sm:fb-right-3"; + } + }; + + return ( +
+
+
+
+ +
+
{children}
+
+
+
+ ); +} diff --git a/packages/surveys/src/components/MultipleChoiceMultiQuestion.tsx b/packages/surveys/src/components/MultipleChoiceMultiQuestion.tsx new file mode 100644 index 00000000000..6a7d6f79641 --- /dev/null +++ b/packages/surveys/src/components/MultipleChoiceMultiQuestion.tsx @@ -0,0 +1,210 @@ +import { h } from "preact"; +import { useEffect, useRef, useState } from "preact/hooks"; +import { TResponseData } from "../../../types/v1/responses"; +import type { TSurveyChoice, TSurveyMultipleChoiceMultiQuestion } from "../../../types/v1/surveys"; +import { cn, shuffleArray } from "../lib/utils"; +import Headline from "./Headline"; +import Subheader from "./Subheader"; +import SubmitButton from "./SubmitButton"; +import _ from "lodash"; +import { BackButton } from "./BackButton"; + +interface MultipleChoiceMultiProps { + question: TSurveyMultipleChoiceMultiQuestion; + onSubmit: (data: TResponseData) => void; + lastQuestion: boolean; + brandColor: string; + storedResponseValue: string[] | null; + goToNextQuestion: (answer: TResponseData) => void; + goToPreviousQuestion?: (answer: TResponseData) => void; +} + +export default function MultipleChoiceMultiQuestion({ + question, + onSubmit, + lastQuestion, + brandColor, + storedResponseValue, + goToNextQuestion, + goToPreviousQuestion, +}: MultipleChoiceMultiProps) { + const [selectedChoices, setSelectedChoices] = useState([]); + const [showOther, setShowOther] = useState(false); + const [otherSpecified, setOtherSpecified] = useState(""); + const [questionChoices, setQuestionChoices] = useState( + question.choices + ? question.shuffleOption && question.shuffleOption !== "none" + ? shuffleArray(question.choices, question.shuffleOption) + : question.choices + : [] + ); + const otherInputRef = useRef(null); + + const isAtLeastOneChecked = () => { + return selectedChoices.length > 0 || otherSpecified.length > 0; + }; + + const nonOtherChoiceLabels = question.choices + .filter((label) => label.id !== "other") + .map((choice) => choice.label); + + useEffect(() => { + const nonOtherSavedChoices = storedResponseValue?.filter((answer) => + nonOtherChoiceLabels.includes(answer) + ); + const savedOtherSpecified = storedResponseValue?.find((answer) => !nonOtherChoiceLabels.includes(answer)); + + setSelectedChoices(nonOtherSavedChoices ?? []); + + if (savedOtherSpecified) { + setOtherSpecified(savedOtherSpecified); + setShowOther(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [storedResponseValue, question.id]); + + useEffect(() => { + if (showOther && otherInputRef.current) { + otherInputRef.current.value = otherSpecified ?? ""; + otherInputRef.current.focus(); + } + }, [otherSpecified, showOther]); + + useEffect(() => { + setQuestionChoices( + question.choices + ? question.shuffleOption && question.shuffleOption !== "none" + ? shuffleArray(question.choices, question.shuffleOption) + : question.choices + : [] + ); + }, [question.choices, question.shuffleOption]); + + const resetForm = () => { + setSelectedChoices([]); // reset value + setShowOther(false); + setOtherSpecified(""); + }; + + const handleSubmit = () => { + const data = { + [question.id]: selectedChoices, + }; + + if (_.xor(selectedChoices, storedResponseValue).length === 0) { + goToNextQuestion(data); + return; + } + + if (question.required && selectedChoices.length <= 0) { + return; + } + + onSubmit(data); + }; + + return ( +
{ + e.preventDefault(); + if (otherSpecified.length > 0 && showOther) { + selectedChoices.push(otherSpecified); + } + handleSubmit(); + resetForm(); + }}> + + +
+
+ Options +
+ {questionChoices.map((choice) => ( + + ))} +
+
+
+ +
+ {goToPreviousQuestion && ( + { + if (otherSpecified.length > 0 && showOther) { + selectedChoices.push(otherSpecified); + } + goToPreviousQuestion({ + [question.id]: selectedChoices, + }); + resetForm(); + }} + /> + )} +
+ {}} + /> +
+ + ); +} diff --git a/packages/surveys/src/components/MultipleChoiceSingleQuestion.tsx b/packages/surveys/src/components/MultipleChoiceSingleQuestion.tsx new file mode 100644 index 00000000000..e86b652d25d --- /dev/null +++ b/packages/surveys/src/components/MultipleChoiceSingleQuestion.tsx @@ -0,0 +1,175 @@ +import { h } from "preact"; +import { useEffect, useRef, useState } from "preact/hooks"; +import { TResponseData } from "../../../types/v1/responses"; +import type { TSurveyChoice, TSurveyMultipleChoiceSingleQuestion } from "../../../types/v1/surveys"; +import { cn, shuffleArray } from "../lib/utils"; +import Headline from "./Headline"; +import Subheader from "./Subheader"; +import SubmitButton from "./SubmitButton"; +import { BackButton } from "./BackButton"; + +interface MultipleChoiceSingleProps { + question: TSurveyMultipleChoiceSingleQuestion; + onSubmit: (data: TResponseData) => void; + lastQuestion: boolean; + brandColor: string; + storedResponseValue: string | null; + goToNextQuestion: (answer: TResponseData) => void; + goToPreviousQuestion?: (answer: TResponseData) => void; +} + +export default function MultipleChoiceSingleQuestion({ + question, + onSubmit, + lastQuestion, + brandColor, + storedResponseValue, + goToNextQuestion, + goToPreviousQuestion, +}: MultipleChoiceSingleProps) { + const storedResponseValueValue = question.choices.find( + (choice) => choice.label === storedResponseValue + )?.id; + const [selectedChoice, setSelectedChoice] = useState(null); + const [savedOtherAnswer, setSavedOtherAnswer] = useState(null); + const [questionChoices, setQuestionChoices] = useState( + question.choices + ? question.shuffleOption && question.shuffleOption !== "none" + ? shuffleArray(question.choices, question.shuffleOption) + : question.choices + : [] + ); + const otherSpecify = useRef(null); + + useEffect(() => { + if (!storedResponseValueValue) { + const otherChoiceId = question.choices.find((choice) => choice.id === "other")?.id; + if (otherChoiceId && storedResponseValue) { + setSelectedChoice(otherChoiceId); + setSavedOtherAnswer(storedResponseValue); + } + } else { + setSelectedChoice(storedResponseValueValue); + } + }, [question.choices, storedResponseValue, storedResponseValueValue]); + + useEffect(() => { + if (selectedChoice === "other") { + otherSpecify.current.value = savedOtherAnswer ?? ""; + otherSpecify.current?.focus(); + } + }, [savedOtherAnswer, selectedChoice]); + + useEffect(() => { + setQuestionChoices( + question.choices + ? question.shuffleOption && question.shuffleOption !== "none" + ? shuffleArray(question.choices, question.shuffleOption) + : question.choices + : [] + ); + }, [question.choices, question.shuffleOption]); + + const resetForm = () => { + setSelectedChoice(null); + setSavedOtherAnswer(null); + }; + + const handleSubmit = (value: string) => { + const data = { + [question.id]: value, + }; + if (value === storedResponseValue) { + goToNextQuestion(data); + resetForm(); // reset form + return; + } + onSubmit(data); + resetForm(); // reset form + }; + + return ( +
{ + e.preventDefault(); + + const value = otherSpecify.current?.value || e.currentTarget[question.id].value; + handleSubmit(value); + }}> + + +
+
+ Options +
+ {questionChoices.map((choice, idx) => ( + + ))} +
+
+
+
+ {goToPreviousQuestion && ( + { + goToPreviousQuestion( + selectedChoice === "other" + ? { + [question.id]: otherSpecify.current?.value, + } + : { + [question.id]: question.choices.find((choice) => choice.id === selectedChoice)?.label, + } + ); + }} + /> + )} +
+ {}} + /> +
+ + ); +} diff --git a/packages/surveys/src/components/NPSQuestion.tsx b/packages/surveys/src/components/NPSQuestion.tsx new file mode 100644 index 00000000000..9dd2079ccee --- /dev/null +++ b/packages/surveys/src/components/NPSQuestion.tsx @@ -0,0 +1,123 @@ +import { h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { TResponseData } from "../../../types/v1/responses"; +import type { TSurveyNPSQuestion } from "../../../types/v1/surveys"; +import { cn } from "../lib/utils"; +import Headline from "./Headline"; +import Subheader from "./Subheader"; +import SubmitButton from "./SubmitButton"; +import { BackButton } from "./BackButton"; + +interface NPSQuestionProps { + question: TSurveyNPSQuestion; + onSubmit: (data: TResponseData) => void; + lastQuestion: boolean; + brandColor: string; + storedResponseValue: number | null; + goToNextQuestion: (answer: TResponseData) => void; + goToPreviousQuestion?: (answer?: TResponseData) => void; +} + +export default function NPSQuestion({ + question, + onSubmit, + lastQuestion, + brandColor, + storedResponseValue, + goToNextQuestion, + goToPreviousQuestion, +}: NPSQuestionProps) { + const [selectedChoice, setSelectedChoice] = useState(null); + useEffect(() => { + setSelectedChoice(storedResponseValue); + }, [storedResponseValue, question]); + + const handleSubmit = (value: number | null) => { + const data = { + [question.id]: value, + }; + if (storedResponseValue === value) { + setSelectedChoice(null); + goToNextQuestion(data); + return; + } + setSelectedChoice(null); + onSubmit(data); + }; + + const handleSelect = (number: number) => { + setSelectedChoice(number); + if (question.required) { + setSelectedChoice(null); + onSubmit({ + [question.id]: number, + }); + } + }; + + return ( +
{ + e.preventDefault(); + handleSubmit(selectedChoice); + }}> + + +
+
+ Options +
+ {Array.from({ length: 11 }, (_, i) => i).map((number) => ( + + ))} +
+
+

{question.lowerLabel}

+

{question.upperLabel}

+
+
+
+ +
+ {goToPreviousQuestion && ( + { + goToPreviousQuestion( + storedResponseValue !== selectedChoice + ? { + [question.id]: selectedChoice, + } + : undefined + ); + }} + /> + )} +
+ {(!question.required || storedResponseValue) && ( + {}} + /> + )} +
+ + ); +} diff --git a/packages/surveys/src/components/OpenTextQuestion.tsx b/packages/surveys/src/components/OpenTextQuestion.tsx new file mode 100644 index 00000000000..8b528143407 --- /dev/null +++ b/packages/surveys/src/components/OpenTextQuestion.tsx @@ -0,0 +1,98 @@ +import { h } from "preact"; +import { TResponseData } from "../../../types/v1/responses"; +import type { TSurveyOpenTextQuestion } from "../../../types/v1/surveys"; +import Headline from "./Headline"; +import Subheader from "./Subheader"; +import SubmitButton from "./SubmitButton"; +import { useEffect, useState } from "preact/hooks"; +import { BackButton } from "./BackButton"; + +interface OpenTextQuestionProps { + question: TSurveyOpenTextQuestion; + onSubmit: (data: TResponseData) => void; + lastQuestion: boolean; + brandColor: string; + storedResponseValue: string | null; + goToNextQuestion: (answer: TResponseData) => void; + goToPreviousQuestion?: (answer: TResponseData) => void; +} + +export default function OpenTextQuestion({ + question, + onSubmit, + lastQuestion, + brandColor, + storedResponseValue, + goToNextQuestion, + goToPreviousQuestion, +}: OpenTextQuestionProps) { + const [value, setValue] = useState(""); + + useEffect(() => { + setValue(storedResponseValue ?? ""); + }, [storedResponseValue, question.id]); + + const handleSubmit = (value: string) => { + const data = { + [question.id]: value, + }; + if (storedResponseValue === value) { + goToNextQuestion(data); + return; + } + onSubmit(data); + setValue(""); // reset value + }; + + return ( +
{ + e.preventDefault(); + handleSubmit(value); + }}> + + +
+ {question.longAnswer === false ? ( + setValue(e.currentTarget.value)} + className="fb-block fb-w-full fb-rounded-md fb-border fb-p-2 fb-shadow-sm focus:fb-ring-0 sm:fb-text-sm fb-bg-slate-50 fb-border-slate-100 focus:fb-border-slate-500 focus:fb-outline-none" + /> + ) : ( + + )} +
+
+ {goToPreviousQuestion && ( + { + goToPreviousQuestion({ + [question.id]: value, + }); + }} + /> + )} +
+ {}} + /> +
+ + ); +} diff --git a/packages/surveys/src/components/Progress.tsx b/packages/surveys/src/components/Progress.tsx new file mode 100644 index 00000000000..ee5f2208176 --- /dev/null +++ b/packages/surveys/src/components/Progress.tsx @@ -0,0 +1,11 @@ +import { h } from "preact"; + +export default function Progress({ progress, brandColor }: { progress: number; brandColor: string }) { + return ( +
+
+
+ ); +} diff --git a/packages/surveys/src/components/QuestionConditional.tsx b/packages/surveys/src/components/QuestionConditional.tsx new file mode 100644 index 00000000000..55d6dfb76d3 --- /dev/null +++ b/packages/surveys/src/components/QuestionConditional.tsx @@ -0,0 +1,102 @@ +import { h } from "preact"; +import { QuestionType } from "../../../types/questions"; +import { TSurveyQuestion } from "../../../types/v1/surveys"; +import OpenTextQuestion from "./OpenTextQuestion"; +import MultipleChoiceSingleQuestion from "./MultipleChoiceSingleQuestion"; +import MultipleChoiceMultiQuestion from "./MultipleChoiceMultiQuestion"; +import NPSQuestion from "./NPSQuestion"; +import CTAQuestion from "./CTAQuestion"; +import RatingQuestion from "./RatingQuestion"; +import ConsentQuestion from "./ConsentQuestion"; + +interface QuestionConditionalProps { + question: TSurveyQuestion; + onSubmit: (data: { [x: string]: any }) => void; + lastQuestion: boolean; + brandColor: string; + storedResponseValue: any; + goToNextQuestion: (answer: any) => void; + goToPreviousQuestion?: (answer: any) => void; +} + +export default function QuestionConditional({ + question, + onSubmit, + lastQuestion, + brandColor, + storedResponseValue, + goToNextQuestion, + goToPreviousQuestion, +}: QuestionConditionalProps) { + return question.type === QuestionType.OpenText ? ( + + ) : question.type === QuestionType.MultipleChoiceSingle ? ( + + ) : question.type === QuestionType.MultipleChoiceMulti ? ( + + ) : question.type === QuestionType.NPS ? ( + + ) : question.type === QuestionType.CTA ? ( + + ) : question.type === QuestionType.Rating ? ( + + ) : question.type === "consent" ? ( + + ) : null; +} diff --git a/packages/surveys/src/components/RatingQuestion.tsx b/packages/surveys/src/components/RatingQuestion.tsx new file mode 100644 index 00000000000..bf0dee85dd4 --- /dev/null +++ b/packages/surveys/src/components/RatingQuestion.tsx @@ -0,0 +1,219 @@ +import { h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { TResponseData } from "../../../types/v1/responses"; +import type { TSurveyRatingQuestion } from "../../../types/v1/surveys"; +import { cn } from "../lib/utils"; +import Headline from "./Headline"; +import { + ConfusedFace, + FrowningFace, + GrinningFaceWithSmilingEyes, + GrinningSquintingFace, + NeutralFace, + PerseveringFace, + SlightlySmilingFace, + SmilingFaceWithSmilingEyes, + TiredFace, + WearyFace, +} from "./Smileys"; +import Subheader from "./Subheader"; +import SubmitButton from "./SubmitButton"; +import { BackButton } from "./BackButton"; + +interface RatingQuestionProps { + question: TSurveyRatingQuestion; + onSubmit: (data: TResponseData) => void; + lastQuestion: boolean; + brandColor: string; + storedResponseValue: number | null; + goToNextQuestion: (answer: TResponseData) => void; + goToPreviousQuestion?: (answer?: TResponseData) => void; +} + +export default function RatingQuestion({ + question, + onSubmit, + lastQuestion, + brandColor, + storedResponseValue, + goToNextQuestion, + goToPreviousQuestion, +}: RatingQuestionProps) { + const [selectedChoice, setSelectedChoice] = useState(null); + const [hoveredNumber, setHoveredNumber] = useState(0); + + useEffect(() => { + setSelectedChoice(storedResponseValue); + }, [storedResponseValue, question]); + + const handleSubmit = (value: number | null) => { + const data = { + [question.id]: value, + }; + if (storedResponseValue === value) { + goToNextQuestion(data); + setSelectedChoice(null); + return; + } + onSubmit(data); + setSelectedChoice(null); + }; + + const handleSelect = (number: number) => { + setSelectedChoice(number); + if (question.required) { + onSubmit({ + [question.id]: number, + }); + setSelectedChoice(null); // reset choice + } + }; + + const HiddenRadioInput = ({ number }) => ( + handleSelect(number)} + required={question.required} + checked={selectedChoice === number} + /> + ); + + return ( +
{ + e.preventDefault(); + handleSubmit(selectedChoice); + }}> + + +
+
+ Choices +
+ {Array.from({ length: question.range }, (_, i) => i + 1).map((number, i, a) => ( + setHoveredNumber(number)} + onMouseLeave={() => setHoveredNumber(0)} + className="fb-relative fb-max-h-10 fb-flex-1 fb-cursor-pointer fb-bg-white fb-text-center fb-text-sm fb-leading-10"> + {question.scale === "number" ? ( + + ) : question.scale === "star" ? ( + + ) : ( + + )} + + ))} +
+
+

{question.lowerLabel}

+

{question.upperLabel}

+
+
+
+ +
+ {goToPreviousQuestion && ( + { + goToPreviousQuestion({ [question.id]: selectedChoice }); + }} + /> + )} +
+ {(!question.required || selectedChoice) && ( + {}} + /> + )} +
+ + ); +} + +interface RatingSmileyProps { + active: boolean; + idx: number; + range: number; +} + +function RatingSmiley({ active, idx, range }: RatingSmileyProps): JSX.Element { + const activeColor = "fb-fill-yellow-500"; + const inactiveColor = "fb-fill-none"; + let icons = [ + , + , + , + , + , + , + , + , + , + , + ]; + + if (range == 7) icons = [icons[1], icons[3], icons[4], icons[5], icons[6], icons[8], icons[9]]; + else if (range == 5) icons = [icons[3], icons[4], icons[5], icons[6], icons[7]]; + else if (range == 4) icons = [icons[4], icons[5], icons[6], icons[7]]; + else if (range == 3) icons = [icons[4], icons[5], icons[7]]; + return icons[idx]; +} diff --git a/packages/surveys/src/components/Smileys.tsx b/packages/surveys/src/components/Smileys.tsx new file mode 100644 index 00000000000..287ef462b98 --- /dev/null +++ b/packages/surveys/src/components/Smileys.tsx @@ -0,0 +1,473 @@ +import { h, FunctionComponent } from "preact"; +import type { JSX } from "preact"; + +export const TiredFace: FunctionComponent> = (props) => { + return ( + + + + + + + + + + + ); +}; + +export const WearyFace: FunctionComponent> = (props) => { + return ( + + + + + + + + + + + ); +}; + +export const PerseveringFace: FunctionComponent> = (props) => { + return ( + + + + + + + + + + + ); +}; + +export const FrowningFace: FunctionComponent> = (props) => { + return ( + + + + + + + + + ); +}; + +export const ConfusedFace: FunctionComponent> = (props) => { + return ( + + + + + + + + + ); +}; + +export const NeutralFace: FunctionComponent> = (props) => { + return ( + + + + + + + + + ); +}; + +export const SlightlySmilingFace: FunctionComponent> = (props) => { + return ( + + + + + + + + + ); +}; + +export const SmilingFaceWithSmilingEyes: FunctionComponent> = ( + props +) => { + return ( + + + + + + + + + ); +}; + +export const GrinningFaceWithSmilingEyes: FunctionComponent> = ( + props +) => { + return ( + + + + + + + + + + ); +}; + +export const GrinningSquintingFace: FunctionComponent> = (props) => { + return ( + + + + + + + + + + + ); +}; + +export let icons = [ + , +]; diff --git a/packages/surveys/src/components/Subheader.tsx b/packages/surveys/src/components/Subheader.tsx new file mode 100644 index 00000000000..09b145308a4 --- /dev/null +++ b/packages/surveys/src/components/Subheader.tsx @@ -0,0 +1,9 @@ +import { h } from "preact"; + +export default function Subheader({ subheader, questionId }: { subheader?: string; questionId: string }) { + return ( + + ); +} diff --git a/packages/surveys/src/components/SubmitButton.tsx b/packages/surveys/src/components/SubmitButton.tsx new file mode 100644 index 00000000000..bca55abce44 --- /dev/null +++ b/packages/surveys/src/components/SubmitButton.tsx @@ -0,0 +1,20 @@ +import { h } from "preact"; + +import { cn } from "@/../../packages/lib/cn"; +import { isLight } from "../lib/utils"; + +function SubmitButton({ question, lastQuestion, brandColor, onClick, type = "submit" }) { + return ( + + ); +} +export default SubmitButton; diff --git a/packages/surveys/src/components/SurveyView.tsx b/packages/surveys/src/components/SurveyView.tsx new file mode 100644 index 00000000000..a08cb67a4d5 --- /dev/null +++ b/packages/surveys/src/components/SurveyView.tsx @@ -0,0 +1,277 @@ +import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; +import type { TJsConfig } from "../../../types/v1/js"; +import type { TResponseData } from "../../../types/v1/responses"; +import type { TSurvey, TSurveyLogic } from "../../../types/v1/surveys"; +import { clearStoredResponse, getStoredResponse, storeResponse } from "../lib/localStorage"; +import { cn } from "../lib/utils"; +import FormbricksSignature from "./FormbricksSignature"; +import Progress from "./Progress"; +import QuestionConditional from "./QuestionConditional"; +import ThankYouCard from "./ThankYouCard"; + +interface SurveyViewProps { + config: TJsConfig; + survey: TSurvey; + onResponse: (response: TResponseData) => void; + onAutoClose: () => void; + brandColor: string; + formbricksSignature?: boolean; +} + +export default function Survey({ + survey, + onResponse, + onAutoClose, + brandColor, + formbricksSignature = true, +}: SurveyViewProps) { + const [activeQuestionId, setActiveQuestionId] = useState(survey.questions[0].id); + const [progress, setProgress] = useState(0); // [0, 1] + const [loadingElement, setLoadingElement] = useState(false); + const contentRef = useRef(null); + const [finished, setFinished] = useState(false); + const [storedResponseValue, setStoredResponseValue] = useState(null); + + const [countdownProgress, setCountdownProgress] = useState(100); + const [countdownStop, setCountdownStop] = useState(false); + const startRef = useRef(performance.now()); + const frameRef = useRef(null); + + const showBackButton = progress !== 0 && !finished; + + const handleStopCountdown = () => { + if (frameRef.current !== null) { + setCountdownStop(true); + cancelAnimationFrame(frameRef.current); + } + }; + + //Scroll to top when question changes + useLayoutEffect(() => { + if (contentRef.current) { + contentRef.current.scrollTop = 0; + } + }, [activeQuestionId]); + + useEffect(() => { + if (!survey.autoClose) return; + const frame = () => { + if (!survey.autoClose || !startRef.current) return; + + const timeout = survey.autoClose * 1000; + const elapsed = performance.now() - startRef.current; + const remaining = Math.max(0, timeout - elapsed); + + setCountdownProgress(remaining / timeout); + + if (remaining > 0) { + frameRef.current = requestAnimationFrame(frame); + } else { + handleStopCountdown(); + onAutoClose(); + } + }; + + setCountdownStop(false); + setCountdownProgress(1); + frameRef.current = requestAnimationFrame(frame); + + return () => { + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current); + } + }; + }, [survey.autoClose, close]); + + useEffect(() => { + setProgress(calculateProgress()); + + function calculateProgress() { + const elementIdx = survey.questions.findIndex((e) => e.id === activeQuestionId); + return elementIdx / survey.questions.length; + } + }, [activeQuestionId, survey]); + + function evaluateCondition(logic: TSurveyLogic, responseValue: any): boolean { + switch (logic.condition) { + case "equals": + return ( + (Array.isArray(responseValue) && + responseValue.length === 1 && + responseValue.includes(logic.value)) || + responseValue.toString() === logic.value + ); + case "notEquals": + return responseValue !== logic.value; + case "lessThan": + return logic.value !== undefined && responseValue < logic.value; + case "lessEqual": + return logic.value !== undefined && responseValue <= logic.value; + case "greaterThan": + return logic.value !== undefined && responseValue > logic.value; + case "greaterEqual": + return logic.value !== undefined && responseValue >= logic.value; + case "includesAll": + return ( + Array.isArray(responseValue) && + Array.isArray(logic.value) && + logic.value.every((v) => responseValue.includes(v)) + ); + case "includesOne": + return ( + Array.isArray(responseValue) && + Array.isArray(logic.value) && + logic.value.some((v) => responseValue.includes(v)) + ); + case "accepted": + return responseValue === "accepted"; + case "clicked": + return responseValue === "clicked"; + case "submitted": + if (typeof responseValue === "string") { + return responseValue !== "dismissed" && responseValue !== "" && responseValue !== null; + } else if (Array.isArray(responseValue)) { + return responseValue.length > 0; + } else if (typeof responseValue === "number") { + return responseValue !== null; + } + return false; + case "skipped": + return ( + (Array.isArray(responseValue) && responseValue.length === 0) || + responseValue === "" || + responseValue === null || + responseValue === "dismissed" + ); + default: + return false; + } + } + + function getNextQuestionId() { + const questions = survey.questions; + const currentQuestionIndex = questions.findIndex((q) => q.id === activeQuestionId); + if (currentQuestionIndex === -1) throw new Error("Question not found"); + + return questions[currentQuestionIndex + 1]?.id || "end"; + } + + function goToNextQuestion(answer: TResponseData): void { + setLoadingElement(true); + const questions = survey.questions; + const nextQuestionId = getNextQuestionId(); + + if (nextQuestionId === "end") { + submitResponse(answer); + return; + } + + const nextQuestion = questions.find((q) => q.id === nextQuestionId); + if (!nextQuestion) throw new Error("Question not found"); + + setStoredResponseValue(getStoredResponse(survey.id, nextQuestionId)); + setActiveQuestionId(nextQuestionId); + setLoadingElement(false); + } + + function getPreviousQuestionId() { + const questions = survey.questions; + const currentQuestionIndex = questions.findIndex((q) => q.id === activeQuestionId); + if (currentQuestionIndex === -1) throw new Error("Question not found"); + + return questions[currentQuestionIndex - 1]?.id; + } + + function goToPreviousQuestion(answer: TResponseData) { + setLoadingElement(true); + const previousQuestionId = getPreviousQuestionId(); + if (!previousQuestionId) throw new Error("Question not found"); + + if (answer) { + storeResponse(survey.id, answer); + } + + setStoredResponseValue(getStoredResponse(survey.id, previousQuestionId)); + setActiveQuestionId(previousQuestionId); + setLoadingElement(false); + } + + const submitResponse = async (data: TResponseData) => { + setLoadingElement(true); + const questions = survey.questions; + const nextQuestionId = getNextQuestionId(); + const currentQuestion = questions[activeQuestionId]; + const responseValue = data[activeQuestionId]; + + if (currentQuestion?.logic && currentQuestion?.logic.length > 0) { + for (let logic of currentQuestion.logic) { + if (!logic.destination) continue; + + if (evaluateCondition(logic, responseValue)) { + return logic.destination; + } + } + } + + await onResponse(responseValue); + setLoadingElement(false); + + if (!finished && nextQuestionId !== "end") { + setStoredResponseValue(getStoredResponse(survey.id, nextQuestionId)); + setActiveQuestionId(nextQuestionId); + } else { + setProgress(100); + setFinished(true); + clearStoredResponse(survey.id); + if (survey.thankYouCard.enabled) { + setTimeout(() => { + close(); + }, 2000); + } else { + close(); + } + } + }; + + return ( +
+ {!countdownStop && survey.autoClose && ( + + )} +
handleStopCountdown()} + onMouseOver={() => handleStopCountdown()}> + {progress === 100 && survey.thankYouCard.enabled ? ( + + ) : ( + survey.questions.map( + (question, idx) => + activeQuestionId === question.id && ( + + ) + ) + )} +
+ {formbricksSignature && } + +
+ ); +} diff --git a/packages/surveys/src/components/ThankYouCard.tsx b/packages/surveys/src/components/ThankYouCard.tsx new file mode 100644 index 00000000000..c5002e8777a --- /dev/null +++ b/packages/surveys/src/components/ThankYouCard.tsx @@ -0,0 +1,38 @@ +import { h } from "preact"; +import Headline from "./Headline"; +import Subheader from "./Subheader"; + +interface ThankYouCardProps { + headline: string; + subheader: string; + brandColor: string; +} + +export default function ThankYouCard({ headline, subheader, brandColor }: ThankYouCardProps) { + return ( +
+
+ + + +
+ + + +
+ + +
+
+ ); +} diff --git a/packages/surveys/src/index.css b/packages/surveys/src/index.css new file mode 100644 index 00000000000..2c3fac689c7 --- /dev/null +++ b/packages/surveys/src/index.css @@ -0,0 +1,69 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/packages/surveys/src/lib/actions.ts b/packages/surveys/src/lib/actions.ts new file mode 100644 index 00000000000..f34096cfd31 --- /dev/null +++ b/packages/surveys/src/lib/actions.ts @@ -0,0 +1,66 @@ +import { TJsActionInput } from "@formbricks/types/v1/js"; +import { Config } from "./config"; +import { NetworkError, Result, err, okVoid } from "./errors"; +import { Logger } from "./logger"; +import { renderWidget } from "./widget"; +import { TSurvey } from "@formbricks/types/v1/surveys"; +const logger = Logger.getInstance(); +const config = Config.getInstance(); + +export const trackAction = async ( + name: string, + properties: TJsActionInput["properties"] = {} +): Promise> => { + const input: TJsActionInput = { + environmentId: config.get().environmentId, + sessionId: config.get().state?.session?.id, + name, + properties: properties || {}, + }; + + const res = await fetch(`${config.get().apiHost}/api/v1/js/actions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + + body: JSON.stringify(input), + }); + + if (!res.ok) { + const error = await res.json(); + + return err({ + code: "network_error", + message: `Error tracking event: ${JSON.stringify(error)}`, + status: res.status, + url: res.url, + responseMessage: error.message, + }); + } + + logger.debug(`Formbricks: Event "${name}" tracked`); + + // get a list of surveys that are collecting insights + const activeSurveys = config.get().state?.surveys; + + if (activeSurveys.length > 0) { + triggerSurvey(name, activeSurveys); + } else { + logger.debug("No active surveys to display"); + } + + return okVoid(); +}; + +export const triggerSurvey = (actionName: string, activeSurveys: TSurvey[]): void => { + for (const survey of activeSurveys) { + for (const trigger of survey.triggers) { + if (trigger.name === actionName) { + logger.debug(`Formbricks: survey ${survey.id} triggered by action "${actionName}"`); + renderWidget(survey); + return; + } + } + } +}; diff --git a/packages/surveys/src/lib/api.ts b/packages/surveys/src/lib/api.ts new file mode 100644 index 00000000000..962e5bdb013 --- /dev/null +++ b/packages/surveys/src/lib/api.ts @@ -0,0 +1,16 @@ +import { FormbricksAPI, EnvironmentId } from "@formbricks/api"; +import { Config } from "./config"; + +export const getApi = (): FormbricksAPI => { + const config = Config.getInstance(); + const { environmentId, apiHost } = config.get(); + + if (!environmentId || !apiHost) { + throw new Error("formbricks.init() must be called before getApi()"); + } + + return new FormbricksAPI({ + apiHost, + environmentId: environmentId as EnvironmentId, + }); +}; diff --git a/packages/surveys/src/lib/automaticActions.ts b/packages/surveys/src/lib/automaticActions.ts new file mode 100644 index 00000000000..2d60dbcb92e --- /dev/null +++ b/packages/surveys/src/lib/automaticActions.ts @@ -0,0 +1,40 @@ +import { trackAction } from "./actions"; +import { err } from "./errors"; + +export const addExitIntentListener = (): void => { + if (typeof document !== "undefined") { + const exitIntentListener = async function (e: MouseEvent) { + if (e.clientY <= 0) { + const trackResult = await trackAction("Exit Intent (Desktop)"); + if (trackResult.ok !== true) { + return err(trackResult.error); + } + } + }; + document.addEventListener("mouseleave", exitIntentListener); + } +}; + +export const addScrollDepthListener = (): void => { + if (typeof window !== "undefined") { + let scrollDepthTriggered = false; + // 'load' event is used to setup listener after full page load + window.addEventListener("load", () => { + window.addEventListener("scroll", async () => { + const scrollPosition = window.pageYOffset; + const windowSize = window.innerHeight; + const bodyHeight = document.documentElement.scrollHeight; + if (scrollPosition === 0) { + scrollDepthTriggered = false; + } + if (!scrollDepthTriggered && scrollPosition / (bodyHeight - windowSize) >= 0.5) { + scrollDepthTriggered = true; + const trackResult = await trackAction("50% Scroll"); + if (trackResult.ok !== true) { + return err(trackResult.error); + } + } + }); + }); + } +}; diff --git a/packages/surveys/src/lib/cleanHtml.ts b/packages/surveys/src/lib/cleanHtml.ts new file mode 100644 index 00000000000..c7b47ba0527 --- /dev/null +++ b/packages/surveys/src/lib/cleanHtml.ts @@ -0,0 +1,79 @@ +/*! + * Sanitize an HTML string + * (c) 2021 Chris Ferdinandi, MIT License, https://gomakethings.com + * @param {String} str The HTML string to sanitize + * @return {String} The sanitized string + */ +export function cleanHtml(str: string): string { + /** + * Convert the string to an HTML document + * @return {Node} An HTML document + */ + function stringToHTML() { + let parser = new DOMParser(); + let doc = parser.parseFromString(str, "text/html"); + return doc.body || document.createElement("body"); + } + + /** + * Remove + + diff --git a/packages/surveys/src/test3.ts b/packages/surveys/src/test3.ts new file mode 100644 index 00000000000..4f88c0f5122 --- /dev/null +++ b/packages/surveys/src/test3.ts @@ -0,0 +1,15 @@ +import { renderSurvey, TResponse } from "@formbricks/surveys"; + +renderSurvey({ + id: "formbricks-survey", + brandColor: "#000000", + onResponse: (response: TResponse) => { + console.log(response); + }, + surveyId: "clkmwo2we000319lx36i6ezjy", +}); + +// HTML +/* +
+; */ diff --git a/packages/surveys/src/test4.html b/packages/surveys/src/test4.html new file mode 100644 index 00000000000..680868f56b8 --- /dev/null +++ b/packages/surveys/src/test4.html @@ -0,0 +1,13 @@ + + + + + + + Vite + Preact + TS + + + + + + diff --git a/packages/surveys/src/vite-env.d.ts b/packages/surveys/src/vite-env.d.ts new file mode 100644 index 00000000000..11f02fe2a00 --- /dev/null +++ b/packages/surveys/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/surveys/tsconfig.json b/packages/surveys/tsconfig.json new file mode 100644 index 00000000000..21abced1d38 --- /dev/null +++ b/packages/surveys/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "jsxImportSource": "preact", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/packages/surveys/tsconfig.node.json b/packages/surveys/tsconfig.node.json new file mode 100644 index 00000000000..42872c59f5b --- /dev/null +++ b/packages/surveys/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/packages/surveys/vite.config.ts b/packages/surveys/vite.config.ts new file mode 100644 index 00000000000..bf9a941c1bd --- /dev/null +++ b/packages/surveys/vite.config.ts @@ -0,0 +1,17 @@ +import { resolve } from "path"; +import { defineConfig } from "vite"; +import preact from "@preact/preset-vite"; + +// https://vitejs.dev/config/ +export default defineConfig({ + build: { + lib: { + // Could also be a dictionary or array of multiple entry points + entry: resolve(__dirname, "src/main.tsx"), + name: "MyLib", + // the proper extensions will be added + fileName: "my-lib", + }, + }, + plugins: [preact()], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc0cb79a0d4..4ead036b531 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 3.12.7 turbo: specifier: latest - version: 1.10.3 + version: 1.10.7 apps/demo: dependencies: @@ -352,7 +352,7 @@ importers: version: 8.10.0(eslint@8.46.0) eslint-config-turbo: specifier: latest - version: 1.10.7(eslint@8.46.0) + version: 1.8.8(eslint@8.46.0) eslint-plugin-react: specifier: 7.33.1 version: 7.33.1(eslint@8.46.0) @@ -475,6 +475,28 @@ importers: specifier: ^0.4.1 version: 0.4.1(prettier@2.8.8) + packages/surveys: + dependencies: + preact: + specifier: ^10.16.0 + version: 10.16.0 + preact-custom-element: + specifier: ^4.2.1 + version: 4.2.1(preact@10.16.0) + devDependencies: + '@preact/preset-vite': + specifier: ^2.5.0 + version: 2.5.0(@babel/core@7.22.9)(preact@10.16.0)(vite@4.4.8) + '@types/preact-custom-element': + specifier: ^4.0.1 + version: 4.0.1 + typescript: + specifier: ^5.0.2 + version: 5.1.6 + vite: + specifier: ^4.4.5 + version: 4.4.8 + packages/tailwind-config: devDependencies: '@tailwindcss/forms': @@ -3901,10 +3923,35 @@ packages: preact: 10.16.0 dev: true + /@preact/preset-vite@2.5.0(@babel/core@7.22.9)(preact@10.16.0)(vite@4.4.8): + resolution: {integrity: sha512-BUhfB2xQ6ex0yPkrT1Z3LbfPzjpJecOZwQ/xJrXGFSZD84+ObyS//41RdEoQCMWsM0t7UHGaujUxUBub7WM1Jw==} + peerDependencies: + '@babel/core': 7.x + vite: 2.x || 3.x || 4.x + dependencies: + '@babel/core': 7.22.9 + '@babel/plugin-transform-react-jsx': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-react-jsx-development': 7.18.6(@babel/core@7.22.9) + '@prefresh/vite': 2.4.1(preact@10.16.0)(vite@4.4.8) + '@rollup/pluginutils': 4.2.1 + babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.22.9) + debug: 4.3.4 + kolorist: 1.8.0 + resolve: 1.22.2 + vite: 4.4.8 + transitivePeerDependencies: + - preact + - supports-color + dev: true + /@prefresh/babel-plugin@0.4.4: resolution: {integrity: sha512-/EvgIFMDL+nd20WNvMO0JQnzIl1EJPgmSaSYrZUww7A+aSdKsi37aL07TljrZR1cBMuzFxcr4xvqsUQLFJEukw==} dev: true + /@prefresh/babel-plugin@0.5.0: + resolution: {integrity: sha512-joAwpkUDwo7ZqJnufXRGzUb+udk20RBgfA8oLPBh5aJH2LeStmV1luBfeJTztPdyCscC2j2SmZ/tVxFRMIxAEw==} + dev: true + /@prefresh/core@1.4.1(preact@10.16.0): resolution: {integrity: sha512-og1vaBj3LMJagVncNrDb37Gqc0cWaUcDbpVt5hZtsN4i2Iwzd/5hyTsDHvlMirhSym3wL9ihU0Xa2VhSaOue7g==} peerDependencies: @@ -3913,10 +3960,39 @@ packages: preact: 10.16.0 dev: true + /@prefresh/core@1.5.1(preact@10.16.0): + resolution: {integrity: sha512-e0mB0Oxtog6ZpKPDBYbzFniFJDIktuKMzOHp7sguntU+ot0yi6dbhJRE9Css1qf0u16wdSZjpL2W2ODWuU05Cw==} + peerDependencies: + preact: ^10.0.0 + dependencies: + preact: 10.16.0 + dev: true + /@prefresh/utils@1.1.3: resolution: {integrity: sha512-Mb9abhJTOV4yCfkXrMrcgFiFT7MfNOw8sDa+XyZBdq/Ai2p4Zyxqsb3EgHLOEdHpMj6J9aiZ54W8H6FTam1u+A==} dev: true + /@prefresh/utils@1.2.0: + resolution: {integrity: sha512-KtC/fZw+oqtwOLUFM9UtiitB0JsVX0zLKNyRTA332sqREqSALIIQQxdUCS1P3xR/jT1e2e8/5rwH6gdcMLEmsQ==} + dev: true + + /@prefresh/vite@2.4.1(preact@10.16.0)(vite@4.4.8): + resolution: {integrity: sha512-vthWmEqu8TZFeyrBNc9YE5SiC3DVSzPgsOCp/WQ7FqdHpOIJi7Z8XvCK06rBPOtG4914S52MjG9Ls22eVAiuqQ==} + peerDependencies: + preact: ^10.4.0 + vite: '>=2.0.0' + dependencies: + '@babel/core': 7.22.9 + '@prefresh/babel-plugin': 0.5.0 + '@prefresh/core': 1.5.1(preact@10.16.0) + '@prefresh/utils': 1.2.0 + '@rollup/pluginutils': 4.2.1 + preact: 10.16.0 + vite: 4.4.8 + transitivePeerDependencies: + - supports-color + dev: true + /@prefresh/webpack@3.3.4(@prefresh/babel-plugin@0.4.4)(preact@10.16.0)(webpack@4.46.0): resolution: {integrity: sha512-RiXS/hvXDup5cQw/267kxkKie81kxaAB7SFbkr8ppshobDEzwgUN1tbGbHNx6Uari0Ql2XByC6HIgQGpaq2Q7w==} peerDependencies: @@ -5700,6 +5776,12 @@ packages: resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} dev: true + /@types/preact-custom-element@4.0.1: + resolution: {integrity: sha512-oduUzzxJAZ7qWJ5EsUffxvpRFnzgffilDlfxLR9Q2YkEzSfn3vudIrnDg0AiQgZJ0GkAjLzkxQyAxH6sS/EQdQ==} + dependencies: + preact: 10.16.0 + dev: true + /@types/prismjs@1.26.0: resolution: {integrity: sha512-ZTaqn/qSqUuAq1YwvOFQfVW1AR/oQJlLSZVustdjwI+GZ8kr0MSHBj0tsXPW1EqHubx50gtBEjbPGsdZwQwCjQ==} @@ -7047,6 +7129,14 @@ packages: resolution: {integrity: sha512-WpOrF76nUHijnNn10eBGOHZmXQC8JYRME9rOLxStOga7Av2VO53ehVFvVNImMksVtQuL2/7ZNxEgxnx7oo/3Hw==} dev: true + /babel-plugin-transform-hook-names@1.0.2(@babel/core@7.22.9): + resolution: {integrity: sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==} + peerDependencies: + '@babel/core': ^7.12.10 + dependencies: + '@babel/core': 7.22.9 + dev: true + /babel-plugin-transform-react-remove-prop-types@0.4.24: resolution: {integrity: sha512-eqj0hVcJUR57/Ug2zE1Yswsw4LhuqqHhD+8v120T1cl3kjg76QwtyBrdIk4WVwK+lAhBJVYCd/v+4nc4y+8JsA==} dev: true @@ -9810,13 +9900,13 @@ packages: eslint: 8.46.0 dev: true - /eslint-config-turbo@1.10.7(eslint@8.46.0): - resolution: {integrity: sha512-0yHt5UlXVph8S4SOvP6gYehLvYjJj6XFKTYOG/WUQbjlcF0OU4pOT1a1juqmmBPWYlvJ0evt7v+RekY4tOopPQ==} + /eslint-config-turbo@1.8.8(eslint@8.46.0): + resolution: {integrity: sha512-+yT22sHOT5iC1sbBXfLIdXfbZuiv9bAyOXsxTxFCWelTeFFnANqmuKB3x274CFvf7WRuZ/vYP/VMjzU9xnFnxA==} peerDependencies: eslint: '>6.6.0' dependencies: eslint: 8.46.0 - eslint-plugin-turbo: 1.10.7(eslint@8.46.0) + eslint-plugin-turbo: 1.8.8(eslint@8.46.0) dev: true /eslint-import-resolver-node@0.3.6: @@ -10004,12 +10094,11 @@ packages: semver: 6.3.1 string.prototype.matchall: 4.0.8 - /eslint-plugin-turbo@1.10.7(eslint@8.46.0): - resolution: {integrity: sha512-YikBHc75DY9VV1vAFUIBekHLQlxqVT5zTNibK8zBQInCUhF7PvyPJc0xXw5FSz8EYtt4uOV3r0Km3CmFRclS4Q==} + /eslint-plugin-turbo@1.8.8(eslint@8.46.0): + resolution: {integrity: sha512-zqyTIvveOY4YU5jviDWw9GXHd4RiKmfEgwsjBrV/a965w0PpDwJgEUoSMB/C/dU310Sv9mF3DSdEjxjJLaw6rA==} peerDependencies: eslint: '>6.6.0' dependencies: - dotenv: 16.0.3 eslint: 8.46.0 dev: true @@ -13109,6 +13198,10 @@ packages: engines: {node: '>= 8'} dev: true + /kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + dev: true + /language-subtag-registry@0.3.22: resolution: {integrity: sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==} @@ -16697,6 +16790,14 @@ packages: - webpack-command dev: true + /preact-custom-element@4.2.1(preact@10.16.0): + resolution: {integrity: sha512-/fiEEAyC+MXRlCBRmv/owoN5BLpO2nF/MF3YBHLtp4C2lNqlhV+a4he74A5DhfDoRmxDHm0sYVgQzWFEyzTDsw==} + peerDependencies: + preact: 10.x + dependencies: + preact: 10.16.0 + dev: false + /preact-render-to-string@5.2.6(preact@10.16.0): resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==} peerDependencies: @@ -17999,6 +18100,14 @@ packages: fsevents: 2.3.2 dev: true + /rollup@3.27.2: + resolution: {integrity: sha512-YGwmHf7h2oUHkVBT248x0yt6vZkYQ3/rvE5iQuVBh3WO8GcJ6BNeOkpoX1yMHIiBm18EMLjBPIoUDkhgnyxGOQ==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.2 + dev: true + /rollup@3.5.1: resolution: {integrity: sha512-hdQWTvPeiAbM6SUkxV70HdGUVxsgsc+CLy5fuh4KdgUBJ0SowXiix8gANgXoG3wEuLwfoJhCT2V+WwxfWq9Ikw==} engines: {node: '>=14.18.0', npm: '>=8.0.0'} @@ -19766,65 +19875,65 @@ packages: dependencies: safe-buffer: 5.2.1 - /turbo-darwin-64@1.10.3: - resolution: {integrity: sha512-IIB9IomJGyD3EdpSscm7Ip1xVWtYb7D0x7oH3vad3gjFcjHJzDz9xZ/iw/qItFEW+wGFcLSRPd+1BNnuLM8AsA==} + /turbo-darwin-64@1.10.7: + resolution: {integrity: sha512-N2MNuhwrl6g7vGuz4y3fFG2aR1oCs0UZ5HKl8KSTn/VC2y2YIuLGedQ3OVbo0TfEvygAlF3QGAAKKtOCmGPNKA==} cpu: [x64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-darwin-arm64@1.10.3: - resolution: {integrity: sha512-SBNmOZU9YEB0eyNIxeeQ+Wi0Ufd+nprEVp41rgUSRXEIpXjsDjyBnKnF+sQQj3+FLb4yyi/yZQckB+55qXWEsw==} + /turbo-darwin-arm64@1.10.7: + resolution: {integrity: sha512-WbJkvjU+6qkngp7K4EsswOriO3xrNQag7YEGRtfLoDdMTk4O4QTeU6sfg2dKfDsBpTidTvEDwgIYJhYVGzrz9Q==} cpu: [arm64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-linux-64@1.10.3: - resolution: {integrity: sha512-kvAisGKE7xHJdyMxZLvg53zvHxjqPK1UVj4757PQqtx9dnjYHSc8epmivE6niPgDHon5YqImzArCjVZJYpIGHQ==} + /turbo-linux-64@1.10.7: + resolution: {integrity: sha512-x1CF2CDP1pDz/J8/B2T0hnmmOQI2+y11JGIzNP0KtwxDM7rmeg3DDTtDM/9PwGqfPotN9iVGgMiMvBuMFbsLhg==} cpu: [x64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-linux-arm64@1.10.3: - resolution: {integrity: sha512-Qgaqln0IYRgyL0SowJOi+PNxejv1I2xhzXOI+D+z4YHbgSx87ox1IsALYBlK8VRVYY8VCXl+PN12r1ioV09j7A==} + /turbo-linux-arm64@1.10.7: + resolution: {integrity: sha512-JtnBmaBSYbs7peJPkXzXxsRGSGBmBEIb6/kC8RRmyvPAMyqF8wIex0pttsI+9plghREiGPtRWv/lfQEPRlXnNQ==} cpu: [arm64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-windows-64@1.10.3: - resolution: {integrity: sha512-rbH9wManURNN8mBnN/ZdkpUuTvyVVEMiUwFUX4GVE5qmV15iHtZfDLUSGGCP2UFBazHcpNHG1OJzgc55GFFrUw==} + /turbo-windows-64@1.10.7: + resolution: {integrity: sha512-7A/4CByoHdolWS8dg3DPm99owfu1aY/W0V0+KxFd0o2JQMTQtoBgIMSvZesXaWM57z3OLsietFivDLQPuzE75w==} cpu: [x64] os: [win32] requiresBuild: true dev: true optional: true - /turbo-windows-arm64@1.10.3: - resolution: {integrity: sha512-ThlkqxhcGZX39CaTjsHqJnqVe+WImjX13pmjnpChz6q5HHbeRxaJSFzgrHIOt0sUUVx90W/WrNRyoIt/aafniw==} + /turbo-windows-arm64@1.10.7: + resolution: {integrity: sha512-D36K/3b6+hqm9IBAymnuVgyePktwQ+F0lSXr2B9JfAdFPBktSqGmp50JNC7pahxhnuCLj0Vdpe9RqfnJw5zATA==} cpu: [arm64] os: [win32] requiresBuild: true dev: true optional: true - /turbo@1.10.3: - resolution: {integrity: sha512-U4gKCWcKgLcCjQd4Pl8KJdfEKumpyWbzRu75A6FCj6Ctea1PIm58W6Ltw1QXKqHrl2pF9e1raAskf/h6dlrPCA==} + /turbo@1.10.7: + resolution: {integrity: sha512-xm0MPM28TWx1e6TNC3wokfE5eaDqlfi0G24kmeHupDUZt5Wd0OzHFENEHMPqEaNKJ0I+AMObL6nbSZonZBV2HA==} hasBin: true requiresBuild: true optionalDependencies: - turbo-darwin-64: 1.10.3 - turbo-darwin-arm64: 1.10.3 - turbo-linux-64: 1.10.3 - turbo-linux-arm64: 1.10.3 - turbo-windows-64: 1.10.3 - turbo-windows-arm64: 1.10.3 + turbo-darwin-64: 1.10.7 + turbo-darwin-arm64: 1.10.7 + turbo-linux-64: 1.10.7 + turbo-linux-arm64: 1.10.7 + turbo-windows-64: 1.10.7 + turbo-windows-arm64: 1.10.7 dev: true /tween-functions@1.2.0: @@ -20467,6 +20576,41 @@ packages: vfile-message: 3.1.3 dev: false + /vite@4.4.8: + resolution: {integrity: sha512-LONawOUUjxQridNWGQlNizfKH89qPigK36XhMI7COMGztz8KNY0JHim7/xDd71CZwGT4HtSRgI7Hy+RlhG0Gvg==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + esbuild: 0.18.10 + postcss: 8.4.27 + rollup: 3.27.2 + optionalDependencies: + fsevents: 2.3.2 + dev: true + /vm-browserify@1.1.2: resolution: {integrity: sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==} dev: true From b3103c83919446bfacd6c616bb3ba4fa6c622383 Mon Sep 17 00:00:00 2001 From: Matthias Nannt Date: Thu, 10 Aug 2023 13:33:34 +0200 Subject: [PATCH 02/40] add renderSurvey method --- packages/surveys/package.json | 4 +- packages/surveys/src/index.ts | 19 ++++ packages/surveys/src/main.tsx | 5 -- packages/surveys/src/test1.ts | 155 -------------------------------- packages/surveys/src/test2.html | 13 --- packages/surveys/src/test3.ts | 15 ---- packages/surveys/src/test4.html | 13 --- pnpm-lock.yaml | 20 ----- 8 files changed, 20 insertions(+), 224 deletions(-) create mode 100644 packages/surveys/src/index.ts delete mode 100644 packages/surveys/src/main.tsx delete mode 100644 packages/surveys/src/test1.ts delete mode 100644 packages/surveys/src/test2.html delete mode 100644 packages/surveys/src/test3.ts delete mode 100644 packages/surveys/src/test4.html diff --git a/packages/surveys/package.json b/packages/surveys/package.json index 5d9db9c671d..2a805dc9e85 100644 --- a/packages/surveys/package.json +++ b/packages/surveys/package.json @@ -9,12 +9,10 @@ "preview": "vite preview" }, "dependencies": { - "preact": "^10.16.0", - "preact-custom-element": "^4.2.1" + "preact": "^10.16.0" }, "devDependencies": { "@preact/preset-vite": "^2.5.0", - "@types/preact-custom-element": "^4.0.1", "typescript": "^5.0.2", "vite": "^4.4.5" } diff --git a/packages/surveys/src/index.ts b/packages/surveys/src/index.ts new file mode 100644 index 00000000000..23fd4881f3e --- /dev/null +++ b/packages/surveys/src/index.ts @@ -0,0 +1,19 @@ +import { h, render } from "preact"; +import Survey from "./Survey"; + +interface RenderSurveyProps { + containerId: string; + brandColor: string; + formbricksSignature: boolean; + onDisplay?: () => void; + onResponse?: () => void; +} + +export const renderSurvey = (props: RenderSurveyProps) => { + const { containerId, ...surveyProps } = props; + const element = document.getElementById(containerId); + if (!element) { + throw new Error(`renderSurvey: Element with id ${containerId} not found.`); + } + render(h(Survey, surveyProps), element); +}; diff --git a/packages/surveys/src/main.tsx b/packages/surveys/src/main.tsx deleted file mode 100644 index 3eca321210e..00000000000 --- a/packages/surveys/src/main.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import register from "preact-custom-element"; - -const Survey = ({ name = "World" }) => ; - -register(Survey, "survey", ["name"]); diff --git a/packages/surveys/src/test1.ts b/packages/surveys/src/test1.ts deleted file mode 100644 index f173d363937..00000000000 --- a/packages/surveys/src/test1.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { renderSurvey } from "@formbricks/surveys"; - -const survey = { - id: "clkqxmrsb0000qhb65bvnl3jo", - createdAt: "2023-07-31T13:55:03.083Z", - updatedAt: "2023-08-02T10:32:48.551Z", - name: "Onboarding Segmentation", - type: "web", - environmentId: "clkmwo2we000319lx36i6ezjy", - status: "inProgress", - questions: [ - { - id: "tjfrd7vl5tba9guvdycl3o6j", - type: "multipleChoiceSingle", - choices: [ - { - id: "ltyyycfznyx80irvq9ibajee", - label: "Founder", - }, - { - id: "dn4i1iy0084xvdiw66wv1aw8", - label: "Executive", - }, - { - id: "s97uhkft8rgsysig65okiutj", - label: "Product Manager", - }, - { - id: "mj6ioso3qa6dg76890vnih2r", - label: "Product Owner", - }, - { - id: "adzlxloi315gsq3yb0myp3zl", - label: "Software Engineer", - }, - ], - headline: "What is your role?", - required: true, - subheader: "Please select one of the following options:", - shuffleOption: "none", - }, - { - id: "egbcs0bd69g5eda9k40vbpp3", - type: "multipleChoiceSingle", - choices: [ - { - id: "yyfh53makxs4j8g80ivt730q", - label: "only me", - }, - { - id: "hi3oq0xoqzb8vb9o0iu6jb2k", - label: "1-5 employees", - }, - { - id: "v5sgmfzv7emzv53yl5ccn1wb", - label: "6-10 employees", - }, - { - id: "h3itbp586acy0howt3nt6m9u", - label: "11-100 employees", - }, - { - id: "yvxxp5j8o73bmki57sgyujm5", - label: "over 100 employees", - }, - ], - headline: "What's your company size?", - required: true, - subheader: "Please select one of the following options:", - shuffleOption: "none", - }, - { - id: "ahs3ua7mxa8j9zu0l1rxggdb", - type: "multipleChoiceSingle", - choices: [ - { - id: "r49lw7zr68guex26fnz76pol", - label: "Recommendation", - }, - { - id: "xzp1f0v8gapisr74gzej6ptw", - label: "Social Media", - }, - { - id: "msg5sac4xxilgxu7zkm299gu", - label: "Ads", - }, - { - id: "cj3dyxf2x4mdoi6os8l974vn", - label: "Google Search", - }, - { - id: "og10iassb2eai911092ro3d8", - label: "In a Podcast", - }, - ], - headline: "How did you hear about us first?", - required: true, - subheader: "Please select one of the following options:", - shuffleOption: "none", - }, - ], - thankYouCard: { - enabled: true, - headline: "Thank you!", - subheader: "We appreciate your feedback.", - }, - displayOption: "respondMultiple", - recontactDays: 0, - autoClose: null, - closeOnDate: null, - delay: 0, - autoComplete: null, - redirectUrl: null, - triggers: [ - { - id: "clkqxnq4h0006qhb6w1qxddy4", - createdAt: "2023-07-31T13:55:47.586Z", - updatedAt: "2023-07-31T13:55:47.586Z", - environmentId: "clkmwo2we000319lx36i6ezjy", - name: "Code Action", - description: null, - type: "code", - noCodeConfig: null, - }, - ], - attributeFilters: [], - displays: [ - { - createdAt: "2023-08-02T14:57:22.837Z", - }, - ], -}; - -const survey = { id: "clkqxmrsb0000qhb65bvnl3jo", ... }; -renderSurvey({ - containerId: "formbricks-survey", - brandColor: "#000000", - formbricksSignature: true, - onResponse: (response) => { - console.log(response); - }, - survey, -}); - -registerSurveyContainer({ - id: "dashboard-survey", - action: "onDashboard" -}); - - -// HTML -/* -
-; */ diff --git a/packages/surveys/src/test2.html b/packages/surveys/src/test2.html deleted file mode 100644 index 8c9840d467a..00000000000 --- a/packages/surveys/src/test2.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Vite + Preact + TS - - - - - - diff --git a/packages/surveys/src/test3.ts b/packages/surveys/src/test3.ts deleted file mode 100644 index 4f88c0f5122..00000000000 --- a/packages/surveys/src/test3.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { renderSurvey, TResponse } from "@formbricks/surveys"; - -renderSurvey({ - id: "formbricks-survey", - brandColor: "#000000", - onResponse: (response: TResponse) => { - console.log(response); - }, - surveyId: "clkmwo2we000319lx36i6ezjy", -}); - -// HTML -/* -
-; */ diff --git a/packages/surveys/src/test4.html b/packages/surveys/src/test4.html deleted file mode 100644 index 680868f56b8..00000000000 --- a/packages/surveys/src/test4.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Vite + Preact + TS - - - - - - diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ead036b531..b40b1f87d85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -480,16 +480,10 @@ importers: preact: specifier: ^10.16.0 version: 10.16.0 - preact-custom-element: - specifier: ^4.2.1 - version: 4.2.1(preact@10.16.0) devDependencies: '@preact/preset-vite': specifier: ^2.5.0 version: 2.5.0(@babel/core@7.22.9)(preact@10.16.0)(vite@4.4.8) - '@types/preact-custom-element': - specifier: ^4.0.1 - version: 4.0.1 typescript: specifier: ^5.0.2 version: 5.1.6 @@ -5776,12 +5770,6 @@ packages: resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} dev: true - /@types/preact-custom-element@4.0.1: - resolution: {integrity: sha512-oduUzzxJAZ7qWJ5EsUffxvpRFnzgffilDlfxLR9Q2YkEzSfn3vudIrnDg0AiQgZJ0GkAjLzkxQyAxH6sS/EQdQ==} - dependencies: - preact: 10.16.0 - dev: true - /@types/prismjs@1.26.0: resolution: {integrity: sha512-ZTaqn/qSqUuAq1YwvOFQfVW1AR/oQJlLSZVustdjwI+GZ8kr0MSHBj0tsXPW1EqHubx50gtBEjbPGsdZwQwCjQ==} @@ -16790,14 +16778,6 @@ packages: - webpack-command dev: true - /preact-custom-element@4.2.1(preact@10.16.0): - resolution: {integrity: sha512-/fiEEAyC+MXRlCBRmv/owoN5BLpO2nF/MF3YBHLtp4C2lNqlhV+a4he74A5DhfDoRmxDHm0sYVgQzWFEyzTDsw==} - peerDependencies: - preact: 10.x - dependencies: - preact: 10.16.0 - dev: false - /preact-render-to-string@5.2.6(preact@10.16.0): resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==} peerDependencies: From e1f3c4255f4df94eafd8739728ff0fac923e7a85 Mon Sep 17 00:00:00 2001 From: Matthias Nannt Date: Thu, 10 Aug 2023 15:38:50 +0200 Subject: [PATCH 03/40] add all survey components --- .../surveys/[surveyId]/edit/SurveyEditor.tsx | 6 +- apps/web/components/shared/Survey.tsx | 21 ++ apps/web/package.json | 1 + packages/js/src/components/BackButton.tsx | 1 - .../MultipleChoiceSingleQuestion.tsx | 2 +- packages/surveys/package.json | 7 +- packages/surveys/src/app.css | 25 --- packages/surveys/src/app.tsx | 33 --- .../surveys/src/components/BackButton.tsx | 4 +- .../surveys/src/components/CTAQuestion.tsx | 3 +- .../src/components/ConsentQuestion.tsx | 7 +- .../src/components/FormbricksSignature.tsx | 2 - packages/surveys/src/components/Headline.tsx | 14 +- packages/surveys/src/components/HtmlBody.tsx | 2 +- packages/surveys/src/components/Modal.tsx | 105 ---------- .../MultipleChoiceMultiQuestion.tsx | 16 +- .../MultipleChoiceSingleQuestion.tsx | 15 +- .../surveys/src/components/NPSQuestion.tsx | 10 +- .../src/components/OpenTextQuestion.tsx | 5 +- packages/surveys/src/components/Progress.tsx | 2 - .../src/components/QuestionConditional.tsx | 9 +- .../surveys/src/components/RatingQuestion.tsx | 16 +- packages/surveys/src/components/Smileys.tsx | 2 +- packages/surveys/src/components/Subheader.tsx | 2 - .../surveys/src/components/SubmitButton.tsx | 15 +- .../components/{SurveyView.tsx => Survey.tsx} | 68 ++++--- .../surveys/src/components/ThankYouCard.tsx | 5 +- packages/surveys/src/index.ts | 4 +- packages/surveys/src/lib/actions.ts | 66 ------ packages/surveys/src/lib/api.ts | 16 -- packages/surveys/src/lib/automaticActions.ts | 40 ---- packages/surveys/src/lib/cleanHtml.ts | 12 +- packages/surveys/src/lib/commandQueue.ts | 61 ------ packages/surveys/src/lib/config.ts | 47 ----- packages/surveys/src/lib/display.ts | 54 ----- packages/surveys/src/lib/errors.ts | 133 ------------ packages/surveys/src/lib/init.ts | 150 -------------- packages/surveys/src/lib/localStorage.ts | 10 +- packages/surveys/src/lib/logger.ts | 47 ----- packages/surveys/src/lib/noCodeEvents.ts | 148 -------------- packages/surveys/src/lib/person.ts | 192 ------------------ packages/surveys/src/lib/response.ts | 58 ------ packages/surveys/src/lib/session.ts | 6 - packages/surveys/src/lib/styles.ts | 12 -- packages/surveys/src/lib/sync.ts | 52 ----- packages/surveys/src/lib/utils.ts | 7 +- packages/surveys/src/lib/widget.ts | 51 ----- packages/surveys/vite.config.ts | 4 +- pnpm-lock.yaml | 3 + 49 files changed, 151 insertions(+), 1420 deletions(-) create mode 100644 apps/web/components/shared/Survey.tsx delete mode 100644 packages/surveys/src/app.css delete mode 100644 packages/surveys/src/app.tsx delete mode 100644 packages/surveys/src/components/Modal.tsx rename packages/surveys/src/components/{SurveyView.tsx => Survey.tsx} (91%) delete mode 100644 packages/surveys/src/lib/actions.ts delete mode 100644 packages/surveys/src/lib/api.ts delete mode 100644 packages/surveys/src/lib/automaticActions.ts delete mode 100644 packages/surveys/src/lib/commandQueue.ts delete mode 100644 packages/surveys/src/lib/config.ts delete mode 100644 packages/surveys/src/lib/display.ts delete mode 100644 packages/surveys/src/lib/errors.ts delete mode 100644 packages/surveys/src/lib/init.ts delete mode 100644 packages/surveys/src/lib/logger.ts delete mode 100644 packages/surveys/src/lib/noCodeEvents.ts delete mode 100644 packages/surveys/src/lib/person.ts delete mode 100644 packages/surveys/src/lib/response.ts delete mode 100644 packages/surveys/src/lib/session.ts delete mode 100644 packages/surveys/src/lib/styles.ts delete mode 100644 packages/surveys/src/lib/sync.ts delete mode 100644 packages/surveys/src/lib/widget.ts diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/SurveyEditor.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/SurveyEditor.tsx index c994d0f6ebc..43b7b1794b3 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/SurveyEditor.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/SurveyEditor.tsx @@ -12,6 +12,7 @@ import QuestionsAudienceTabs from "./QuestionsAudienceTabs"; import QuestionsView from "./QuestionsView"; import SurveyMenuBar from "./SurveyMenuBar"; import { useEnvironment } from "@/lib/environments/environments"; +import { SurveyView } from "@/components/shared/Survey"; interface SurveyEditorProps { environmentId: string; @@ -78,7 +79,8 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr )}
diff --git a/apps/web/components/shared/Survey.tsx b/apps/web/components/shared/Survey.tsx new file mode 100644 index 00000000000..43e26e6e4a4 --- /dev/null +++ b/apps/web/components/shared/Survey.tsx @@ -0,0 +1,21 @@ +import { renderSurvey } from "@formbricks/surveys"; +import { Survey } from "@formbricks/types/surveys"; +import { useEffect } from "react"; + +interface SurveyProps { + survey: Survey; + brandColor: string; + formbricksSignature: boolean; +} + +export const SurveyView = ({ survey }: SurveyProps) => { + useEffect(() => { + renderSurvey({ + survey, + brandColor: "#000", + formbricksSignature: true, + containerId: "formbricks-survey", + }); + }); + return
; +}; diff --git a/apps/web/package.json b/apps/web/package.json index 6c4e5fba2bf..e500635914f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "@formbricks/errors": "workspace:*", "@formbricks/js": "workspace:*", "@formbricks/lib": "workspace:*", + "@formbricks/surveys": "workspace:*", "@formbricks/types": "workspace:*", "@formbricks/ui": "workspace:*", "@headlessui/react": "^1.7.16", diff --git a/packages/js/src/components/BackButton.tsx b/packages/js/src/components/BackButton.tsx index 7d23350b7f5..e16c40e7474 100644 --- a/packages/js/src/components/BackButton.tsx +++ b/packages/js/src/components/BackButton.tsx @@ -1,5 +1,4 @@ import { h } from "preact"; - import { cn } from "@/../../packages/lib/cn"; interface BackButtonProps { diff --git a/packages/js/src/components/MultipleChoiceSingleQuestion.tsx b/packages/js/src/components/MultipleChoiceSingleQuestion.tsx index 7fae48eca50..42490530ef8 100644 --- a/packages/js/src/components/MultipleChoiceSingleQuestion.tsx +++ b/packages/js/src/components/MultipleChoiceSingleQuestion.tsx @@ -109,7 +109,7 @@ export default function MultipleChoiceSingleQuestion({ selectedChoice === choice.label ? "fb-z-10 fb-bg-slate-50 fb-border-slate-400" : "fb-border-gray-200", - "fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-rounded-md fb-border fb-p-4 focus:fb-outline-none hover:bg-slate-50 fb-text-slate-800" + "fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-rounded-md fb-border fb-p-4 focus:fb-outline-none fb-text-slate-800 hover:bg-slate-50" )}> -
- - - - - - -
-

Vite + Preact

-
- -

- Edit src/app.tsx and save to test HMR -

-
-

- Click on the Vite and Preact logos to learn more -

- - ) -} diff --git a/packages/surveys/src/components/BackButton.tsx b/packages/surveys/src/components/BackButton.tsx index 7d23350b7f5..7f26b021903 100644 --- a/packages/surveys/src/components/BackButton.tsx +++ b/packages/surveys/src/components/BackButton.tsx @@ -1,6 +1,4 @@ -import { h } from "preact"; - -import { cn } from "@/../../packages/lib/cn"; +import { cn } from "../../../lib/cn"; interface BackButtonProps { onClick: () => void; diff --git a/packages/surveys/src/components/CTAQuestion.tsx b/packages/surveys/src/components/CTAQuestion.tsx index 936872d6463..26f3767c264 100644 --- a/packages/surveys/src/components/CTAQuestion.tsx +++ b/packages/surveys/src/components/CTAQuestion.tsx @@ -1,10 +1,9 @@ -import { h } from "preact"; import { TResponseData } from "../../../types/v1/responses"; import type { TSurveyCTAQuestion } from "../../../types/v1/surveys"; +import { BackButton } from "./BackButton"; import Headline from "./Headline"; import HtmlBody from "./HtmlBody"; import SubmitButton from "./SubmitButton"; -import { BackButton } from "./BackButton"; interface CTAQuestionProps { question: TSurveyCTAQuestion; diff --git a/packages/surveys/src/components/ConsentQuestion.tsx b/packages/surveys/src/components/ConsentQuestion.tsx index 565a8e62b92..30a4606ce08 100644 --- a/packages/surveys/src/components/ConsentQuestion.tsx +++ b/packages/surveys/src/components/ConsentQuestion.tsx @@ -1,11 +1,10 @@ -import { h } from "preact"; +import { useEffect, useState } from "preact/hooks"; import { TResponseData } from "../../../types/v1/responses"; import type { TSurveyConsentQuestion } from "../../../types/v1/surveys"; +import { BackButton } from "./BackButton"; import Headline from "./Headline"; import HtmlBody from "./HtmlBody"; import SubmitButton from "./SubmitButton"; -import { useEffect, useState } from "preact/hooks"; -import { BackButton } from "./BackButton"; interface ConsentQuestionProps { question: TSurveyConsentQuestion; @@ -59,7 +58,7 @@ export default function ConsentQuestion({ e.preventDefault(); handleSumbit(answer); }}> -