diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary.tsx new file mode 100644 index 0000000000..e15d1645cd --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary.tsx @@ -0,0 +1,78 @@ +import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline"; +import { questionTypes } from "@/app/lib/questions"; +import { InboxStackIcon } from "@heroicons/react/24/solid"; +import Link from "next/link"; + +import { getPersonIdentifier } from "@formbricks/lib/person/util"; +import { timeSince } from "@formbricks/lib/time"; +import { TSurveyCalQuestion, TSurveyQuestionSummary } from "@formbricks/types/surveys"; +import { PersonAvatar } from "@formbricks/ui/Avatars"; + +interface CalSummaryProps { + questionSummary: TSurveyQuestionSummary; + environmentId: string; +} + +export default function CalSummary({ questionSummary, environmentId }: CalSummaryProps) { + const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type); + + return ( +
+
+ + +
+
+ {questionTypeInfo && } + {questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question +
+
+ + {questionSummary.responses.length} Responses +
+
+
+
+
+
User
+
Response
+
Time
+
+ {questionSummary.responses.map((response) => { + const displayIdentifier = response.person ? getPersonIdentifier(response.person) : null; + return ( +
+
+ {response.person ? ( + +
+ +
+

+ {displayIdentifier} +

+ + ) : ( +
+
+ +
+

Anonymous

+
+ )} +
+
+ {response.value} +
+
{timeSince(response.updatedAt.toISOString())}
+
+ ); + })} +
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx index b1505e7360..a0bce72259 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx @@ -1,4 +1,5 @@ import EmptyInAppSurveys from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys"; +import CalSummary from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary"; import ConsentSummary from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary"; import HiddenFieldsSummary from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary"; @@ -6,6 +7,7 @@ import { TEnvironment } from "@formbricks/types/environment"; import { TResponse } from "@formbricks/types/responses"; import { TSurveyQuestionType } from "@formbricks/types/surveys"; import type { + TSurveyCalQuestion, TSurveyDateQuestion, TSurveyFileUploadQuestion, TSurveyPictureSelectionQuestion, @@ -159,6 +161,16 @@ export default function SummaryList({ environment, survey, responses, responsesP ); } + if (questionSummary.question.type === TSurveyQuestionType.Cal) { + return ( + } + environmentId={environment.id} + /> + ); + } + return null; })} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate.tsx index 7d9d3c2761..a0effd89f6 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate.tsx @@ -284,6 +284,18 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) => ); + case TSurveyQuestionType.Cal: + return ( + + + {firstQuestion.subheader} + + + You have been invited to schedule a meet via cal.com Open Survey to continue{" "} + + + + ); case TSurveyQuestionType.Date: return ( diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CalQuestionForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CalQuestionForm.tsx new file mode 100644 index 0000000000..a06f5f796c --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CalQuestionForm.tsx @@ -0,0 +1,81 @@ +import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid"; +import { useState } from "react"; + +import { TSurveyCalQuestion } from "@formbricks/types/surveys"; +import { Button } from "@formbricks/ui/Button"; +import { Input } from "@formbricks/ui/Input"; +import { Label } from "@formbricks/ui/Label"; + +interface CalQuestionFormProps { + question: TSurveyCalQuestion; + questionIdx: number; + updateQuestion: (questionIdx: number, updatedAttributes: any) => void; + lastQuestion: boolean; + isInValid: boolean; +} + +export default function CalQuestionForm({ + question, + questionIdx, + updateQuestion, + isInValid, +}: CalQuestionFormProps): JSX.Element { + const [showSubheader, setShowSubheader] = useState(!!question.subheader); + + return ( +
+
+ +
+ updateQuestion(questionIdx, { headline: e.target.value })} + isInvalid={isInValid && question.headline.trim() === ""} + /> +
+
+
+ {showSubheader && ( + <> + +
+ updateQuestion(questionIdx, { subheader: e.target.value })} + /> + { + setShowSubheader(false); + updateQuestion(questionIdx, { subheader: "" }); + }} + /> +
+ + )} + {!showSubheader && ( + + )} +
+ +
+ updateQuestion(questionIdx, { calUserName: e.target.value })} + /> +
+
+
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor.tsx index 28b9cb5f24..25faa59c00 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor.tsx @@ -83,6 +83,7 @@ export default function LogicEditor({ consent: ["skipped", "accepted"], pictureSelection: ["submitted", "skipped"], fileUpload: ["uploaded", "notUploaded"], + cal: ["skipped", "booked"], }; const logicConditions: LogicConditions = { @@ -101,16 +102,6 @@ export default function LogicEditor({ values: null, unique: true, }, - uploaded: { - label: "has uploaded file", - values: null, - unique: true, - }, - notUploaded: { - label: "has not uploaded file", - values: null, - unique: true, - }, clicked: { label: "is clicked", values: null, @@ -150,6 +141,21 @@ export default function LogicEditor({ values: questionValues, multiSelect: true, }, + uploaded: { + label: "has uploaded file", + values: null, + unique: true, + }, + notUploaded: { + label: "has not uploaded file", + values: null, + unique: true, + }, + booked: { + label: "has a call booked", + values: null, + unique: true, + }, }; const addLogic = () => { diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx index faac0d4efb..7eb3aee521 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx @@ -13,6 +13,7 @@ import { ChevronRightIcon, CursorArrowRippleIcon, ListBulletIcon, + PhoneIcon, PhotoIcon, PresentationChartBarIcon, QueueListIcon, @@ -31,6 +32,7 @@ import { Label } from "@formbricks/ui/Label"; import { Switch } from "@formbricks/ui/Switch"; import CTAQuestionForm from "./CTAQuestionForm"; +import CalQuestionForm from "./CalQuestionForm"; import ConsentQuestionForm from "./ConsentQuestionForm"; import FileUploadQuestionForm from "./FileUploadQuestionForm"; import MultipleChoiceMultiForm from "./MultipleChoiceMultiForm"; @@ -152,6 +154,8 @@ export default function QuestionCard({ ) : question.type === TSurveyQuestionType.Date ? ( + ) : question.type === TSurveyQuestionType.Cal ? ( + ) : null}
@@ -268,6 +272,14 @@ export default function QuestionCard({ lastQuestion={lastQuestion} isInValid={isInValid} /> + ) : question.type === TSurveyQuestionType.Cal ? ( + ) : null}
diff --git a/apps/web/app/lib/questions.ts b/apps/web/app/lib/questions.ts index 4674c4a87e..2e5acf7e15 100644 --- a/apps/web/app/lib/questions.ts +++ b/apps/web/app/lib/questions.ts @@ -5,6 +5,7 @@ import { CheckIcon, CursorArrowRippleIcon, ListBulletIcon, + PhoneIcon, PhotoIcon, PresentationChartBarIcon, QueueListIcon, @@ -156,6 +157,17 @@ export const questionTypes: TSurveyQuestionType[] = [ allowMultipleFiles: false, }, }, + { + id: QuestionId.Cal, + label: "Schedule a meeting", + description: "Allow respondents to schedule a meet", + icon: PhoneIcon, + preset: { + headline: "Schedule a call with me", + buttonLabel: "Skip", + calUserName: "rick/get-rick-rolled", + }, + }, ]; export const universalQuestionPresets = { diff --git a/packages/js/package.json b/packages/js/package.json index 1ba1e915e5..ecdd08b7ed 100644 --- a/packages/js/package.json +++ b/packages/js/package.json @@ -1,7 +1,7 @@ { "name": "@formbricks/js", "license": "MIT", - "version": "1.2.8", + "version": "1.2.9", "description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.", "homepage": "https://formbricks.com", "repository": { diff --git a/packages/surveys/package.json b/packages/surveys/package.json index 386f8cb19d..7fc806e74b 100644 --- a/packages/surveys/package.json +++ b/packages/surveys/package.json @@ -49,6 +49,7 @@ "vite-plugin-dts": "^3.6.4", "vite-tsconfig-paths": "^4.2.2", "serve": "14.2.1", - "concurrently": "8.2.2" + "concurrently": "8.2.2", + "@calcom/embed-snippet": "1.1.2" } } diff --git a/packages/surveys/src/components/general/CalEmbed.tsx b/packages/surveys/src/components/general/CalEmbed.tsx new file mode 100644 index 0000000000..b6c376d951 --- /dev/null +++ b/packages/surveys/src/components/general/CalEmbed.tsx @@ -0,0 +1,56 @@ +import { cn } from "@/lib/utils"; +import snippet from "@calcom/embed-snippet"; +import { useEffect, useMemo } from "preact/hooks"; + +import { TSurveyCalQuestion } from "@formbricks/types/surveys"; + +interface CalEmbedProps { + question: TSurveyCalQuestion; + onSuccessfulBooking: () => void; +} + +export default function CalEmbed({ question, onSuccessfulBooking }: CalEmbedProps) { + const cal = useMemo(() => { + const calInline = snippet("https://cal.com/embed.js"); + + const calCssVars = { + "cal-border-subtle": "transparent", + "cal-border-booker": "transparent", + }; + + calInline("ui", { + theme: "light", + cssVarsPerTheme: { + light: { + ...calCssVars, + }, + dark: { + "cal-bg-muted": "transparent", + "cal-bg": "transparent", + ...calCssVars, + }, + }, + }); + + calInline("on", { + action: "bookingSuccessful", + callback: () => { + onSuccessfulBooking(); + }, + }); + + return calInline; + }, [onSuccessfulBooking]); + + useEffect(() => { + // remove any existing cal-inline elements + document.querySelectorAll("cal-inline").forEach((el) => el.remove()); + cal("inline", { elementOrSelector: "#fb-cal-embed", calLink: question.calUserName }); + }, [cal, question.calUserName]); + + return ( +
+
+
+ ); +} diff --git a/packages/surveys/src/components/general/QuestionConditional.tsx b/packages/surveys/src/components/general/QuestionConditional.tsx index 8fef2a0e13..aa0e1d629b 100644 --- a/packages/surveys/src/components/general/QuestionConditional.tsx +++ b/packages/surveys/src/components/general/QuestionConditional.tsx @@ -1,4 +1,5 @@ import CTAQuestion from "@/components/questions/CTAQuestion"; +import CalQuestion from "@/components/questions/CalQuestion"; import ConsentQuestion from "@/components/questions/ConsentQuestion"; import DateQuestion from "@/components/questions/DateQuestion"; import FileUploadQuestion from "@/components/questions/FileUploadQuestion"; @@ -165,5 +166,17 @@ export default function QuestionConditional({ ttc={ttc} setTtc={setTtc} /> + ) : question.type === TSurveyQuestionType.Cal ? ( + ) : null; } diff --git a/packages/surveys/src/components/questions/CalQuestion.tsx b/packages/surveys/src/components/questions/CalQuestion.tsx new file mode 100644 index 0000000000..d803014f94 --- /dev/null +++ b/packages/surveys/src/components/questions/CalQuestion.tsx @@ -0,0 +1,93 @@ +import { BackButton } from "@/components/buttons/BackButton"; +import SubmitButton from "@/components/buttons/SubmitButton"; +import CalEmbed from "@/components/general/CalEmbed"; +import Headline from "@/components/general/Headline"; +import Subheader from "@/components/general/Subheader"; +import { getUpdatedTtc, useTtc } from "@/lib/ttc"; +import { useCallback, useState } from "preact/hooks"; + +import { TResponseData } from "@formbricks/types/responses"; +import { TResponseTtc } from "@formbricks/types/responses"; +import { TSurveyCalQuestion } from "@formbricks/types/surveys"; + +interface CalQuestionProps { + question: TSurveyCalQuestion; + value: string | number | string[]; + onChange: (responseData: TResponseData) => void; + onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; + onBack: () => void; + isFirstQuestion: boolean; + isLastQuestion: boolean; + ttc: TResponseTtc; + setTtc: (ttc: TResponseTtc) => void; +} + +export default function CalQuestion({ + question, + value, + onChange, + onSubmit, + onBack, + isFirstQuestion, + isLastQuestion, + ttc, + setTtc, +}: CalQuestionProps) { + const [startTime, setStartTime] = useState(performance.now()); + useTtc(question.id, ttc, setTtc, startTime, setStartTime); + + const [errorMessage, setErrorMessage] = useState(""); + + const onSuccessfulBooking = useCallback(() => { + onChange({ [question.id]: "booked" }); + const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime); + setTtc(updatedttc); + onSubmit({ [question.id]: "booked" }, updatedttc); + }, [onChange, onSubmit, question.id, setTtc, startTime, ttc]); + + return ( +
{ + e.preventDefault(); + if (question.required && !value) { + setErrorMessage("Please book an appointment"); + return; + } + + const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime); + setTtc(updatedttc); + + onChange({ [question.id]: value }); + onSubmit({ [question.id]: value }, updatedttc); + }} + className="w-full"> + + + + + <> + {errorMessage && {errorMessage}} + + + +
+ {!isFirstQuestion && ( + { + onBack(); + }} + /> + )} +
+ {!question.required && ( + {}} + /> + )} +
+ + ); +} diff --git a/packages/surveys/src/components/questions/DateQuestion.tsx b/packages/surveys/src/components/questions/DateQuestion.tsx index e6f9f07a87..aca4f80d73 100644 --- a/packages/surveys/src/components/questions/DateQuestion.tsx +++ b/packages/surveys/src/components/questions/DateQuestion.tsx @@ -114,7 +114,6 @@ export default function DateQuestion({ onSubmit={(e) => { e.preventDefault(); if (question.required && !value) { - // alert("Please select a date"); setErrorMessage("Please select a date."); return; } diff --git a/packages/types/surveys.ts b/packages/types/surveys.ts index 3b4635627c..b2cf71f08d 100644 --- a/packages/types/surveys.ts +++ b/packages/types/surveys.ts @@ -19,6 +19,7 @@ export enum TSurveyQuestionType { Rating = "rating", Consent = "consent", PictureSelection = "pictureSelection", + Cal = "cal", Date = "date", } @@ -129,6 +130,7 @@ export const ZSurveyLogicCondition = z.enum([ "includesOne", "uploaded", "notUploaded", + "booked", ]); export type TSurveyLogicCondition = z.infer; @@ -207,6 +209,11 @@ const ZSurveyPictureSelectionLogic = ZSurveyLogicBase.extend({ value: z.undefined(), }); +const ZSurveyCalLogic = ZSurveyLogicBase.extend({ + condition: z.enum(["booked", "skipped"]).optional(), + value: z.undefined(), +}); + export const ZSurveyLogic = z.union([ ZSurveyOpenTextLogic, ZSurveyConsentLogic, @@ -217,6 +224,7 @@ export const ZSurveyLogic = z.union([ ZSurveyRatingLogic, ZSurveyPictureSelectionLogic, ZSurveyFileUploadLogic, + ZSurveyCalLogic, ]); export type TSurveyLogic = z.infer; @@ -236,16 +244,6 @@ const ZSurveyQuestionBase = z.object({ isDraft: z.boolean().optional(), }); -export const ZSurveyFileUploadQuestion = ZSurveyQuestionBase.extend({ - type: z.literal(TSurveyQuestionType.FileUpload), - allowMultipleFiles: z.boolean(), - maxSizeInMB: z.number().optional(), - allowedFileExtensions: z.array(ZAllowedFileExtension).optional(), - logic: z.array(ZSurveyFileUploadLogic).optional(), -}); - -export type TSurveyFileUploadQuestion = z.infer; - export const ZSurveyOpenTextQuestionInputType = z.enum(["text", "email", "url", "number", "phone"]); export type TSurveyOpenTextQuestionInputType = z.infer; @@ -347,6 +345,24 @@ export const ZSurveyPictureSelectionQuestion = ZSurveyQuestionBase.extend({ export type TSurveyPictureSelectionQuestion = z.infer; +export const ZSurveyFileUploadQuestion = ZSurveyQuestionBase.extend({ + type: z.literal(TSurveyQuestionType.FileUpload), + allowMultipleFiles: z.boolean(), + maxSizeInMB: z.number().optional(), + allowedFileExtensions: z.array(ZAllowedFileExtension).optional(), + logic: z.array(ZSurveyFileUploadLogic).optional(), +}); + +export type TSurveyFileUploadQuestion = z.infer; + +export const ZSurveyCalQuestion = ZSurveyQuestionBase.extend({ + type: z.literal(TSurveyQuestionType.Cal), + calUserName: z.string(), + logic: z.array(ZSurveyCalLogic).optional(), +}); + +export type TSurveyCalQuestion = z.infer; + export const ZSurveyQuestion = z.union([ ZSurveyOpenTextQuestion, ZSurveyConsentQuestion, @@ -358,6 +374,7 @@ export const ZSurveyQuestion = z.union([ ZSurveyPictureSelectionQuestion, ZSurveyDateQuestion, ZSurveyFileUploadQuestion, + ZSurveyCalQuestion, ]); export type TSurveyQuestion = z.infer; @@ -454,6 +471,7 @@ export const ZSurveyTSurveyQuestionType = z.union([ z.literal("rating"), z.literal("consent"), z.literal("pictureSelection"), + z.literal("cal"), z.literal("date"), ]); diff --git a/packages/ui/SingleResponseCard/index.tsx b/packages/ui/SingleResponseCard/index.tsx index ed64dc3ad1..56ba7b13e9 100644 --- a/packages/ui/SingleResponseCard/index.tsx +++ b/packages/ui/SingleResponseCard/index.tsx @@ -333,6 +333,10 @@ export default function SingleResponseCard({
) : question.type === TSurveyQuestionType.Date ? ( + ) : question.type === TSurveyQuestionType.Cal ? ( +

+ {response.data[question.id]} +

) : (

{response.data[question.id]} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2447b73f53..b563fd0132 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -712,6 +712,9 @@ importers: packages/surveys: devDependencies: + '@calcom/embed-snippet': + specifier: 1.1.2 + version: 1.1.2 '@formbricks/lib': specifier: workspace:* version: link:../lib @@ -3031,7 +3034,6 @@ packages: /@calcom/embed-core@1.3.2: resolution: {integrity: sha512-qxVfWpmPcYN5hTnwoKTP9QAlhEAHy4TFh+Xu+IoCnJma/uI2BjqsUWJ0BXsmm0m8sTFthaBkGiFomS1LeMYO+Q==} - dev: false /@calcom/embed-react@1.3.0(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-NbqkLd6J+VUy8jILY4GI1HEXRv3pZk1wnmL3CRrouB2y7pU5sV1qPjkgy+hwCRlYJJGiAIrspXkI/czMsfHxRQ==} @@ -3049,7 +3051,6 @@ packages: resolution: {integrity: sha512-UKz4BRyxWLPfCIr7FfZP2Aa8w3ZMXcfwc3frCjNfWphJvJjaCLi0nAUBXFx6ooIPhVkbzvelnkllbqigZRZPiA==} dependencies: '@calcom/embed-core': 1.3.2 - dev: false /@commander-js/extra-typings@9.4.1(commander@9.4.1): resolution: {integrity: sha512-v0BqORYamk1koxDon6femDGLWSL7P78vYTyOU5nFaALnmNALL+ktgdHvWbxzzBBJIKS7kv3XvM/DqNwiLcgFTA==}