feat: New Question Type Meet Scheduling with Cal.com (#1722)

Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Naitik Kapadia
2023-12-19 13:38:04 +05:30
committed by GitHub
parent 45f02fd3c2
commit b275cce7ad
16 changed files with 423 additions and 25 deletions

View File

@@ -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<TSurveyCalQuestion>;
environmentId: string;
}
export default function CalSummary({ questionSummary, environmentId }: CalSummaryProps) {
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
return (
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<Headline headline={questionSummary.question.headline} />
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="flex items-center rounded-lg bg-slate-100 p-2 ">
{questionTypeInfo && <questionTypeInfo.icon className="mr-2 h-4 w-4 " />}
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
<InboxStackIcon className="mr-2 h-4 w-4" />
{questionSummary.responses.length} Responses
</div>
</div>
</div>
<div className="rounded-b-lg bg-white ">
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
<div className="pl-4 md:pl-6">User</div>
<div className="col-span-2 pl-4 md:pl-6">Response</div>
<div className="px-4 md:px-6">Time</div>
</div>
{questionSummary.responses.map((response) => {
const displayIdentifier = response.person ? getPersonIdentifier(response.person) : null;
return (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{displayIdentifier}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
</div>
)}
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold capitalize">
{response.value}
</div>
<div className="px-4 text-slate-500 md:px-6">{timeSince(response.updatedAt.toISOString())}</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -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 (
<CalSummary
key={questionSummary.question.id}
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyCalQuestion>}
environmentId={environment.id}
/>
);
}
return null;
})}

View File

@@ -284,6 +284,18 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) =>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.Cal:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
You have been invited to schedule a meet via cal.com Open Survey to continue{" "}
</Text>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.Date:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>

View File

@@ -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 (
<form>
<div className="mt-3">
<Label htmlFor="headline">Question</Label>
<div className="mt-2">
<Input
autoFocus
id="headline"
name="headline"
value={question.headline}
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
isInvalid={isInValid && question.headline.trim() === ""}
/>
</div>
</div>
<div className="mt-3">
{showSubheader && (
<>
<Label htmlFor="subheader">Description</Label>
<div className="mt-2 inline-flex w-full items-center">
<Input
id="subheader"
name="subheader"
value={question.subheader}
onChange={(e) => updateQuestion(questionIdx, { subheader: e.target.value })}
/>
<TrashIcon
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => {
setShowSubheader(false);
updateQuestion(questionIdx, { subheader: "" });
}}
/>
</div>
</>
)}
{!showSubheader && (
<Button size="sm" variant="minimal" type="button" onClick={() => setShowSubheader(true)}>
<PlusIcon className="mr-1 h-4 w-4" />
Add Description
</Button>
)}
<div className="mt-3">
<Label htmlFor="calUserName">Add your Cal.com username or username/event</Label>
<div className="mt-2">
<Input
id="calUserName"
name="calUserName"
value={question.calUserName}
onChange={(e) => updateQuestion(questionIdx, { calUserName: e.target.value })}
/>
</div>
</div>
</div>
</form>
);
}

View File

@@ -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 = () => {

View File

@@ -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({
<PhotoIcon />
) : question.type === TSurveyQuestionType.Date ? (
<CalendarDaysIcon />
) : question.type === TSurveyQuestionType.Cal ? (
<PhoneIcon />
) : null}
</div>
<div>
@@ -268,6 +272,14 @@ export default function QuestionCard({
lastQuestion={lastQuestion}
isInValid={isInValid}
/>
) : question.type === TSurveyQuestionType.Cal ? (
<CalQuestionForm
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
isInValid={isInValid}
/>
) : null}
<div className="mt-4">
<Collapsible.Root open={openAdvanced} onOpenChange={setOpenAdvanced} className="mt-5">

View File

@@ -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 = {

View File

@@ -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": {

View File

@@ -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"
}
}

View File

@@ -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 (
<div className="relative mt-4">
<div id="fb-cal-embed" className={cn("h-96 overflow-auto rounded-lg border border-slate-200")} />
</div>
);
}

View File

@@ -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 ? (
<CalQuestion
question={question}
value={value}
onChange={onChange}
onSubmit={onSubmit}
onBack={onBack}
isFirstQuestion={isFirstQuestion}
isLastQuestion={isLastQuestion}
ttc={ttc}
setTtc={setTtc}
/>
) : null;
}

View File

@@ -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 (
<form
onSubmit={(e) => {
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">
<Headline headline={question.headline} questionId={question.id} required={question.required} />
<Subheader subheader={question.subheader} questionId={question.id} />
<>
{errorMessage && <span className="text-red-500">{errorMessage}</span>}
<CalEmbed key={question.id} question={question} onSuccessfulBooking={onSuccessfulBooking} />
</>
<div className="mt-4 flex w-full justify-between">
{!isFirstQuestion && (
<BackButton
backButtonLabel={question.backButtonLabel}
onClick={() => {
onBack();
}}
/>
)}
<div></div>
{!question.required && (
<SubmitButton
buttonLabel={question.buttonLabel}
isLastQuestion={isLastQuestion}
onClick={() => {}}
/>
)}
</div>
</form>
);
}

View File

@@ -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;
}

View File

@@ -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<typeof ZSurveyLogicCondition>;
@@ -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<typeof ZSurveyLogic>;
@@ -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<typeof ZSurveyFileUploadQuestion>;
export const ZSurveyOpenTextQuestionInputType = z.enum(["text", "email", "url", "number", "phone"]);
export type TSurveyOpenTextQuestionInputType = z.infer<typeof ZSurveyOpenTextQuestionInputType>;
@@ -347,6 +345,24 @@ export const ZSurveyPictureSelectionQuestion = ZSurveyQuestionBase.extend({
export type TSurveyPictureSelectionQuestion = z.infer<typeof ZSurveyPictureSelectionQuestion>;
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<typeof ZSurveyFileUploadQuestion>;
export const ZSurveyCalQuestion = ZSurveyQuestionBase.extend({
type: z.literal(TSurveyQuestionType.Cal),
calUserName: z.string(),
logic: z.array(ZSurveyCalLogic).optional(),
});
export type TSurveyCalQuestion = z.infer<typeof ZSurveyCalQuestion>;
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<typeof ZSurveyQuestion>;
@@ -454,6 +471,7 @@ export const ZSurveyTSurveyQuestionType = z.union([
z.literal("rating"),
z.literal("consent"),
z.literal("pictureSelection"),
z.literal("cal"),
z.literal("date"),
]);

View File

@@ -333,6 +333,10 @@ export default function SingleResponseCard({
</div>
) : question.type === TSurveyQuestionType.Date ? (
<DateResponse date={response.data[question.id] as string} />
) : question.type === TSurveyQuestionType.Cal ? (
<p className="ph-no-capture my-1 font-semibold capitalize text-slate-700">
{response.data[question.id]}
</p>
) : (
<p className="ph-no-capture my-1 font-semibold text-slate-700">
{response.data[question.id]}

5
pnpm-lock.yaml generated
View File

@@ -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==}