mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-26 16:30:21 -06:00
Compare commits
2 Commits
shubham/e2
...
@formbrick
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acc6674ec5 | ||
|
|
dd0d296c6a |
@@ -0,0 +1,95 @@
|
||||
import React, { useState } from "react";
|
||||
import { getPersonIdentifier } from "@formbricks/lib/person/util";
|
||||
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
||||
import { timeSince } from "@formbricks/lib/time";
|
||||
import type { TSurveyDateQuestion, TSurveyQuestionSummary } from "@formbricks/types/surveys";
|
||||
import { PersonAvatar } from "@formbricks/ui/Avatars";
|
||||
import { InboxStackIcon } from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
import { questionTypes } from "@/app/lib/questions";
|
||||
import { formatDateWithOrdinal } from "@formbricks/lib/utils/datetime";
|
||||
|
||||
interface DateQuestionSummary {
|
||||
questionSummary: TSurveyQuestionSummary<TSurveyDateQuestion>;
|
||||
environmentId: string;
|
||||
responsesPerPage: number;
|
||||
}
|
||||
|
||||
export default function DateQuestionSummary({
|
||||
questionSummary,
|
||||
environmentId,
|
||||
responsesPerPage,
|
||||
}: DateQuestionSummary) {
|
||||
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
|
||||
const [displayCount, setDisplayCount] = useState(responsesPerPage);
|
||||
|
||||
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} required={questionSummary.question.required} />
|
||||
|
||||
<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.slice(0, displayCount).map((response) => {
|
||||
const displayIdentifier = getPersonIdentifier(response.person!);
|
||||
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">
|
||||
{formatDateWithOrdinal(new Date(response.value as string))}
|
||||
</div>
|
||||
<div className="px-4 text-slate-500 md:px-6">{timeSince(response.updatedAt.toISOString())}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="my-1 flex justify-center">
|
||||
{displayCount < questionSummary.responses.length && (
|
||||
<button
|
||||
onClick={() => setDisplayCount((prevCount) => prevCount + responsesPerPage)}
|
||||
className="my-2 flex h-8 items-center justify-center rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-700">
|
||||
Show more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import HiddenFieldsSummary from "@/app/(app)/environments/[environmentId]/survey
|
||||
import EmptySpaceFiller from "@formbricks/ui/EmptySpaceFiller";
|
||||
import { TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import type {
|
||||
TSurveyDateQuestion,
|
||||
TSurveyFileUploadQuestion,
|
||||
TSurveyPictureSelectionQuestion,
|
||||
TSurveyQuestionSummary,
|
||||
@@ -26,6 +27,7 @@ import MultipleChoiceSummary from "./MultipleChoiceSummary";
|
||||
import NPSSummary from "./NPSSummary";
|
||||
import OpenTextSummary from "./OpenTextSummary";
|
||||
import RatingSummary from "./RatingSummary";
|
||||
import DateQuestionSummary from "./DateQuestionSummary";
|
||||
import FileUploadSummary from "./FileUploadSummary";
|
||||
import PictureChoiceSummary from "./PictureChoiceSummary";
|
||||
|
||||
@@ -47,6 +49,7 @@ export default function SummaryList({ environment, survey, responses, responsesP
|
||||
updatedAt: r.updatedAt,
|
||||
person: r.person,
|
||||
}));
|
||||
|
||||
return {
|
||||
question,
|
||||
responses: questionResponses,
|
||||
@@ -54,115 +57,123 @@ export default function SummaryList({ environment, survey, responses, responsesP
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-10 space-y-8">
|
||||
{survey.type === "web" && responses.length === 0 && !environment.widgetSetupCompleted ? (
|
||||
<EmptyInAppSurveys environment={environment} />
|
||||
) : responses.length === 0 ? (
|
||||
<EmptySpaceFiller
|
||||
type="response"
|
||||
environment={environment}
|
||||
noWidgetRequired={survey.type === "link"}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{getSummaryData().map((questionSummary) => {
|
||||
if (questionSummary.question.type === TSurveyQuestionType.OpenText) {
|
||||
return (
|
||||
<OpenTextSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyOpenTextQuestion>}
|
||||
environmentId={environment.id}
|
||||
responsesPerPage={responsesPerPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (
|
||||
questionSummary.question.type === TSurveyQuestionType.MultipleChoiceSingle ||
|
||||
questionSummary.question.type === TSurveyQuestionType.MultipleChoiceMulti
|
||||
) {
|
||||
return (
|
||||
<MultipleChoiceSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={
|
||||
questionSummary as TSurveyQuestionSummary<
|
||||
TSurveyMultipleChoiceMultiQuestion | TSurveyMultipleChoiceSingleQuestion
|
||||
>
|
||||
}
|
||||
environmentId={environment.id}
|
||||
surveyType={survey.type}
|
||||
responsesPerPage={responsesPerPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.NPS) {
|
||||
return (
|
||||
<NPSSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyNPSQuestion>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.CTA) {
|
||||
return (
|
||||
<CTASummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyCTAQuestion>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.Rating) {
|
||||
return (
|
||||
<RatingSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyRatingQuestion>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.Consent) {
|
||||
return (
|
||||
<ConsentSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyConsentQuestion>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.FileUpload) {
|
||||
return (
|
||||
<FileUploadSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyFileUploadQuestion>}
|
||||
environmentId={environment.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.PictureSelection) {
|
||||
return (
|
||||
<PictureChoiceSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={
|
||||
questionSummary as TSurveyQuestionSummary<TSurveyPictureSelectionQuestion>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
<div className="mt-10 space-y-8">
|
||||
{survey.type === "web" && responses.length === 0 && !environment.widgetSetupCompleted ? (
|
||||
<EmptyInAppSurveys environment={environment} />
|
||||
) : responses.length === 0 ? (
|
||||
<EmptySpaceFiller
|
||||
type="response"
|
||||
environment={environment}
|
||||
noWidgetRequired={survey.type === "link"}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{getSummaryData().map((questionSummary) => {
|
||||
if (questionSummary.question.type === TSurveyQuestionType.OpenText) {
|
||||
return (
|
||||
<OpenTextSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyOpenTextQuestion>}
|
||||
environmentId={environment.id}
|
||||
responsesPerPage={responsesPerPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (
|
||||
questionSummary.question.type === TSurveyQuestionType.MultipleChoiceSingle ||
|
||||
questionSummary.question.type === TSurveyQuestionType.MultipleChoiceMulti
|
||||
) {
|
||||
return (
|
||||
<MultipleChoiceSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={
|
||||
questionSummary as TSurveyQuestionSummary<
|
||||
TSurveyMultipleChoiceMultiQuestion | TSurveyMultipleChoiceSingleQuestion
|
||||
>
|
||||
}
|
||||
environmentId={environment.id}
|
||||
surveyType={survey.type}
|
||||
responsesPerPage={responsesPerPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.NPS) {
|
||||
return (
|
||||
<NPSSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyNPSQuestion>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.CTA) {
|
||||
return (
|
||||
<CTASummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyCTAQuestion>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.Rating) {
|
||||
return (
|
||||
<RatingSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyRatingQuestion>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.Consent) {
|
||||
return (
|
||||
<ConsentSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyConsentQuestion>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.PictureSelection) {
|
||||
return (
|
||||
<PictureChoiceSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyPictureSelectionQuestion>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.Date) {
|
||||
return (
|
||||
<DateQuestionSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyDateQuestion>}
|
||||
environmentId={environment.id}
|
||||
responsesPerPage={responsesPerPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.FileUpload) {
|
||||
return (
|
||||
<FileUploadSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyFileUploadQuestion>}
|
||||
environmentId={environment.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
|
||||
{survey.hiddenFields?.enabled &&
|
||||
survey.hiddenFields.fieldIds?.map((question) => {
|
||||
return (
|
||||
<HiddenFieldsSummary
|
||||
environment={environment}
|
||||
question={question}
|
||||
responses={responses}
|
||||
survey={survey}
|
||||
key={question}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{survey.hiddenFields?.enabled &&
|
||||
survey.hiddenFields.fieldIds?.map((question) => {
|
||||
return (
|
||||
<HiddenFieldsSummary
|
||||
environment={environment}
|
||||
question={question}
|
||||
responses={responses}
|
||||
survey={survey}
|
||||
key={question}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { render } from "@react-email/render";
|
||||
import { CalendarDaysIcon } from "lucide-react";
|
||||
|
||||
interface EmailTemplateProps {
|
||||
survey: TSurvey;
|
||||
@@ -283,6 +284,22 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) =>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.Date:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Section className="mt-4 flex h-12 w-full items-center justify-center rounded-lg border border-solid border-gray-200 bg-white">
|
||||
<CalendarDaysIcon className="mb-1 inline h-4 w-4" />
|
||||
<Text className="inline text-sm font-medium">Select a date</Text>
|
||||
</Section>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { TSurvey, TSurveyDateQuestion } from "@formbricks/types/surveys";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
|
||||
import { useState } from "react";
|
||||
import QuestionFormInput from "./QuestionFormInput";
|
||||
import { OptionsSwitcher } from "@formbricks/ui/QuestionTypeSelector";
|
||||
|
||||
interface IDateQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyDateQuestion;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
}
|
||||
|
||||
const dateOptions = [
|
||||
{
|
||||
value: "M-d-y",
|
||||
label: "MM-DD-YYYY",
|
||||
},
|
||||
{
|
||||
value: "d-M-y",
|
||||
label: "DD-MM-YYYY",
|
||||
},
|
||||
{
|
||||
value: "y-M-d",
|
||||
label: "YYYY-MM-DD",
|
||||
},
|
||||
];
|
||||
|
||||
export default function DateQuestionForm({
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
isInValid,
|
||||
localSurvey,
|
||||
}: IDateQuestionFormProps): JSX.Element {
|
||||
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
|
||||
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
environmentId={localSurvey.environmentId}
|
||||
isInValid={isInValid}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="questionType">Date Format</Label>
|
||||
<div className="mt-2 flex items-center">
|
||||
<OptionsSwitcher
|
||||
options={dateOptions}
|
||||
currentOption={question.format}
|
||||
handleTypeChange={(value) => updateQuestion(questionIdx, { format: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -9,16 +9,23 @@ import {
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { QuestionTypeSelector } from "@formbricks/ui/QuestionTypeSelector";
|
||||
import { OptionsSwitcher } from "@formbricks/ui/QuestionTypeSelector";
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ChatBubbleBottomCenterTextIcon,
|
||||
EnvelopeIcon,
|
||||
HashtagIcon,
|
||||
LinkIcon,
|
||||
PhoneIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
|
||||
const questionTypes = [
|
||||
{ value: "text", label: "Text" },
|
||||
{ value: "email", label: "Email" },
|
||||
{ value: "url", label: "URL" },
|
||||
{ value: "number", label: "Number" },
|
||||
{ value: "phone", label: "Phone" },
|
||||
{ value: "text", label: "Text", icon: <ChatBubbleBottomCenterTextIcon /> },
|
||||
{ value: "email", label: "Email", icon: <EnvelopeIcon /> },
|
||||
{ value: "url", label: "URL", icon: <LinkIcon /> },
|
||||
{ value: "number", label: "Number", icon: <HashtagIcon /> },
|
||||
{ value: "phone", label: "Phone", icon: <PhoneIcon /> },
|
||||
];
|
||||
|
||||
interface OpenQuestionFormProps {
|
||||
@@ -106,9 +113,9 @@ export default function OpenQuestionForm({
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="questionType">Input Type</Label>
|
||||
<div className="mt-2 flex items-center">
|
||||
<QuestionTypeSelector
|
||||
questionTypes={questionTypes}
|
||||
currentType={question.inputType}
|
||||
<OptionsSwitcher
|
||||
options={questionTypes}
|
||||
currentOption={question.inputType}
|
||||
handleTypeChange={handleInputChange} // Use the merged function
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
StarIcon,
|
||||
ArrowUpTrayIcon,
|
||||
PhotoIcon,
|
||||
CalendarDaysIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useState } from "react";
|
||||
@@ -33,6 +34,7 @@ import NPSQuestionForm from "./NPSQuestionForm";
|
||||
import OpenQuestionForm from "./OpenQuestionForm";
|
||||
import QuestionDropdown from "./QuestionMenu";
|
||||
import RatingQuestionForm from "./RatingQuestionForm";
|
||||
import DateQuestionForm from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/DateQuestionForm";
|
||||
import PictureSelectionForm from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/PictureSelectionForm";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
|
||||
@@ -146,6 +148,8 @@ export default function QuestionCard({
|
||||
<CheckIcon />
|
||||
) : question.type === TSurveyQuestionType.PictureSelection ? (
|
||||
<PhotoIcon />
|
||||
) : question.type === TSurveyQuestionType.Date ? (
|
||||
<CalendarDaysIcon />
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
@@ -234,6 +238,15 @@ export default function QuestionCard({
|
||||
updateQuestion={updateQuestion}
|
||||
isInValid={isInValid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Date ? (
|
||||
<DateQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.PictureSelection ? (
|
||||
<PictureSelectionForm
|
||||
localSurvey={localSurvey}
|
||||
|
||||
@@ -2,7 +2,7 @@ export const revalidate = REVALIDATION_INTERVAL;
|
||||
import { getActionClasses } from "@formbricks/lib/actionClass/service";
|
||||
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import { REVALIDATION_INTERVAL, colours } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
@@ -12,7 +12,6 @@ import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { colours } from "@formbricks/lib/constants";
|
||||
import SurveyEditor from "./components/SurveyEditor";
|
||||
|
||||
export const generateMetadata = async ({ params }) => {
|
||||
@@ -59,17 +58,15 @@ export default async function SurveysEditPage({ params }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SurveyEditor
|
||||
survey={survey}
|
||||
product={product}
|
||||
environment={environment}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
responseCount={responseCount}
|
||||
membershipRole={currentUserMembership?.role}
|
||||
colours={colours}
|
||||
/>
|
||||
</>
|
||||
<SurveyEditor
|
||||
survey={survey}
|
||||
product={product}
|
||||
environment={environment}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
responseCount={responseCount}
|
||||
membershipRole={currentUserMembership?.role}
|
||||
colours={colours}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,11 +20,14 @@ export default function Modal({
|
||||
const modalRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const highlightBorderColorStyle = useMemo(() => {
|
||||
if (!highlightBorderColor) return {};
|
||||
if (!highlightBorderColor)
|
||||
return {
|
||||
overflow: "visible",
|
||||
};
|
||||
|
||||
return {
|
||||
border: `2px solid ${highlightBorderColor}`,
|
||||
overflow: "hidden",
|
||||
overflow: "visible",
|
||||
};
|
||||
}, [highlightBorderColor]);
|
||||
|
||||
@@ -51,7 +54,7 @@ export default function Modal({
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div aria-live="assertive" className="relative h-full w-full overflow-hidden">
|
||||
<div aria-live="assertive" className="relative h-full w-full overflow-visible">
|
||||
<div
|
||||
ref={modalRef}
|
||||
style={highlightBorderColorStyle}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
|
||||
|
||||
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.log(error.message);
|
||||
console.error(error.message);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,13 +2,14 @@ import { TSurveyQuestionType as QuestionId } from "@formbricks/types/surveys";
|
||||
import {
|
||||
ArrowUpTrayIcon,
|
||||
ChatBubbleBottomCenterTextIcon,
|
||||
CheckIcon,
|
||||
CursorArrowRippleIcon,
|
||||
ListBulletIcon,
|
||||
PhotoIcon,
|
||||
PresentationChartBarIcon,
|
||||
QueueListIcon,
|
||||
StarIcon,
|
||||
CheckIcon,
|
||||
CalendarDaysIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { replaceQuestionPresetPlaceholders } from "./templates";
|
||||
@@ -133,6 +134,16 @@ export const questionTypes: TSurveyQuestionType[] = [
|
||||
dismissButtonLabel: "Skip",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: QuestionId.Date,
|
||||
label: "Date",
|
||||
description: "Ask your users to select a date",
|
||||
icon: CalendarDaysIcon,
|
||||
preset: {
|
||||
headline: "When is your birthday?",
|
||||
format: "M-d-y",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: QuestionId.FileUpload,
|
||||
label: "File Upload",
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
"license": "MIT",
|
||||
"version": "1.2.7",
|
||||
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
|
||||
"homepage": "https://formbricks.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/formbricks/formbricks"
|
||||
},
|
||||
"keywords": [
|
||||
"Formbricks",
|
||||
"surveys",
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
import "server-only";
|
||||
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { SERVICES_REVALIDATION_INTERVAL, ITEMS_PER_PAGE } from "../constants";
|
||||
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/actionClasses";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { actionClassCache } from "./cache";
|
||||
|
||||
@@ -198,6 +199,9 @@ export const updateActionClass = async (
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new DatabaseError(`Database error when updating an action for environment ${environmentId}`);
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,3 +5,34 @@ export const diffInDays = (date1: Date, date2: Date) => {
|
||||
const diffTime = Math.abs(date2.getTime() - date1.getTime());
|
||||
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
};
|
||||
|
||||
export const formatDateWithOrdinal = (date: Date): string => {
|
||||
const getOrdinalSuffix = (day: number) => {
|
||||
const suffixes = ["th", "st", "nd", "rd"];
|
||||
const relevantDigits = day < 30 ? day % 20 : day % 30;
|
||||
return suffixes[relevantDigits <= 3 ? relevantDigits : 0];
|
||||
};
|
||||
|
||||
const dayOfWeekNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
const monthNames = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
const dayOfWeek = dayOfWeekNames[date.getDay()];
|
||||
const day = date.getDate();
|
||||
const monthIndex = date.getMonth();
|
||||
const year = date.getFullYear();
|
||||
|
||||
return `${dayOfWeek}, ${monthNames[monthIndex]} ${day}${getOrdinalSuffix(day)}, ${year}`;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
{
|
||||
"name": "@formbricks/surveys",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.1",
|
||||
"description": "Formbricks-surveys is a helper library to embed surveys into your application",
|
||||
"homepage": "https://formbricks.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/formbricks/formbricks"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"source": "./src/index.ts",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.mjs",
|
||||
@@ -15,9 +23,11 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"build": "tsc && vite build",
|
||||
"go": "vite build --watch",
|
||||
"dev": "SURVEYS_PACKAGE_MODE=development vite build --watch",
|
||||
"build": "pnpm run build:surveys && pnpm run build:question-date",
|
||||
"build:surveys": "tsc && SURVEYS_PACKAGE_BUILD=surveys vite build",
|
||||
"build:question-date": "tsc && SURVEYS_PACKAGE_BUILD=question-date vite build",
|
||||
"go": "concurrently \"pnpm dev\" \"serve dist -p 3003\"",
|
||||
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
|
||||
"preview": "vite preview",
|
||||
"clean": "rimraf .turbo node_modules dist"
|
||||
@@ -28,14 +38,17 @@
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@preact/preset-vite": "^2.7.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "latest",
|
||||
"postcss": "^8.4.32",
|
||||
"preact": "^10.19.2",
|
||||
"react-date-picker": "^10.5.2",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"terser": "^5.25.0",
|
||||
"vite": "^5.0.6",
|
||||
"vite-plugin-dts": "^3.6.4",
|
||||
"vite-tsconfig-paths": "^4.2.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "latest"
|
||||
"serve": "14.2.1",
|
||||
"concurrently": "8.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import NPSQuestion from "@/components/questions/NPSQuestion";
|
||||
import OpenTextQuestion from "@/components/questions/OpenTextQuestion";
|
||||
import PictureSelectionQuestion from "@/components/questions/PictureSelectionQuestion";
|
||||
import RatingQuestion from "@/components/questions/RatingQuestion";
|
||||
import DateQuestion from "@/components/questions/DateQuestion";
|
||||
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
|
||||
import { TUploadFileConfig } from "@formbricks/types/storage";
|
||||
import { TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
@@ -125,6 +126,18 @@ export default function QuestionConditional({
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Date ? (
|
||||
<DateQuestion
|
||||
question={question}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSubmit={onSubmit}
|
||||
onBack={onBack}
|
||||
isFirstQuestion={isFirstQuestion}
|
||||
isLastQuestion={isLastQuestion}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.PictureSelection ? (
|
||||
<PictureSelectionQuestion
|
||||
question={question}
|
||||
|
||||
160
packages/surveys/src/components/questions/DateQuestion.tsx
Normal file
160
packages/surveys/src/components/questions/DateQuestion.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { BackButton } from "@/components/buttons/BackButton";
|
||||
import SubmitButton from "@/components/buttons/SubmitButton";
|
||||
import Headline from "@/components/general/Headline";
|
||||
import Subheader from "@/components/general/Subheader";
|
||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyDateQuestion } from "@formbricks/types/surveys";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
interface DateQuestionProps {
|
||||
question: TSurveyDateQuestion;
|
||||
value: string | number | string[];
|
||||
onChange: (responseData: TResponseData) => void;
|
||||
onSubmit: (data: TResponseData, ttc: TResponseTtc) => void;
|
||||
onBack: () => void;
|
||||
isFirstQuestion: boolean;
|
||||
isLastQuestion: boolean;
|
||||
autoFocus?: boolean;
|
||||
ttc: TResponseTtc;
|
||||
setTtc: (ttc: TResponseTtc) => void;
|
||||
}
|
||||
|
||||
export default function DateQuestion({
|
||||
question,
|
||||
value,
|
||||
onSubmit,
|
||||
onBack,
|
||||
isFirstQuestion,
|
||||
isLastQuestion,
|
||||
onChange,
|
||||
setTtc,
|
||||
ttc,
|
||||
}: DateQuestionProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime);
|
||||
|
||||
const defaultDate = value ? new Date(value as string) : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
// Check if the DatePicker has already been loaded
|
||||
|
||||
if (!window.initDatePicker) {
|
||||
const script = document.createElement("script");
|
||||
|
||||
script.src =
|
||||
process.env.SURVEYS_PACKAGE_MODE === "development"
|
||||
? "http://localhost:3003/question-date.umd.js"
|
||||
: "https://unpkg.com/@formbricks/surveys@^1.0.1/dist/question-date.umd.js";
|
||||
|
||||
script.async = true;
|
||||
|
||||
document.body.appendChild(script);
|
||||
|
||||
script.onload = () => {
|
||||
// Initialize the DatePicker once the script is loaded
|
||||
// @ts-expect-error
|
||||
window.initDatePicker(document.getElementById("date-picker-root"), defaultDate, question.format);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return () => {
|
||||
document.body.removeChild(script);
|
||||
};
|
||||
} else {
|
||||
// If already loaded, remove the date picker and re-initialize it
|
||||
setLoading(false);
|
||||
|
||||
const datePickerContainer = document.getElementById("datePickerContainer");
|
||||
if (datePickerContainer) {
|
||||
datePickerContainer.remove();
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
window.initDatePicker(document.getElementById("date-picker-root"), defaultDate, question.format);
|
||||
}
|
||||
|
||||
return () => {};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [question.format, question.id]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("dateChange", (e) => {
|
||||
// @ts-expect-error
|
||||
const date = e.detail as Date;
|
||||
|
||||
// Get the timezone offset in minutes and convert it to milliseconds
|
||||
const timezoneOffset = date.getTimezoneOffset() * 60000;
|
||||
|
||||
// Adjust the date by subtracting the timezone offset
|
||||
const adjustedDate = new Date(date.getTime() - timezoneOffset);
|
||||
|
||||
// Format the date as YYYY-MM-DD
|
||||
const dateString = adjustedDate.toISOString().split("T")[0];
|
||||
|
||||
onChange({ [question.id]: dateString });
|
||||
});
|
||||
}, [onChange, question.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value && errorMessage) {
|
||||
setErrorMessage("");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (question.required && !value) {
|
||||
// alert("Please select a date");
|
||||
setErrorMessage("Please select a date.");
|
||||
return;
|
||||
}
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onSubmit({ [question.id]: value }, updatedTtcObj);
|
||||
}}
|
||||
className="w-full">
|
||||
<Headline headline={question.headline} questionId={question.id} required={question.required} />
|
||||
<Subheader subheader={question.subheader} questionId={question.id} />
|
||||
|
||||
<div className={"text-red-600"}>
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
|
||||
<div className={cn("my-4", errorMessage && "rounded-lg border-2 border-red-500")} id="date-picker-root">
|
||||
{loading && (
|
||||
<div className="relative flex h-12 w-full cursor-pointer appearance-none items-center justify-center rounded-lg border border-slate-300 bg-white text-left text-base font-normal text-slate-900 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1">
|
||||
<span
|
||||
className="h-6 w-6 animate-spin rounded-full border-b-2 border-neutral-900"
|
||||
style={{ borderTopColor: "transparent" }}></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex w-full justify-between">
|
||||
<div>
|
||||
{!isFirstQuestion && (
|
||||
<BackButton
|
||||
backButtonLabel={question.backButtonLabel}
|
||||
onClick={() => {
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onBack();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SubmitButton isLastQuestion={isLastQuestion} onClick={() => {}} buttonLabel={question.buttonLabel} />
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -68,12 +68,14 @@ export default function Modal({
|
||||
};
|
||||
|
||||
const highlightBorderColorStyle = useMemo(() => {
|
||||
if (!highlightBorderColor) return {};
|
||||
if (!highlightBorderColor)
|
||||
return {
|
||||
overflow: "visible",
|
||||
};
|
||||
|
||||
return {
|
||||
borderRadius: "8px",
|
||||
border: "2px solid",
|
||||
overflow: "hidden",
|
||||
borderColor: highlightBorderColor,
|
||||
};
|
||||
}, [highlightBorderColor]);
|
||||
@@ -101,7 +103,7 @@ export default function Modal({
|
||||
className={cn(
|
||||
getPlacementStyle(placement),
|
||||
show ? "opacity-100" : "opacity-0",
|
||||
"border-border pointer-events-auto absolute bottom-0 h-fit w-full overflow-hidden rounded-lg border bg-white shadow-lg transition-all duration-500 ease-in-out sm:m-4 sm:max-w-sm"
|
||||
"border-border pointer-events-auto absolute bottom-0 h-fit w-full overflow-visible rounded-lg border bg-white shadow-lg transition-all duration-500 ease-in-out sm:m-4 sm:max-w-sm"
|
||||
)}>
|
||||
{!isCenter && (
|
||||
<div class="absolute right-0 top-0 block pr-2 pt-2">
|
||||
|
||||
126
packages/surveys/src/sideload/question-date/Question.tsx
Normal file
126
packages/surveys/src/sideload/question-date/Question.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useState, useEffect, useMemo } from "preact/hooks";
|
||||
import DatePicker from "react-date-picker";
|
||||
|
||||
const CalendarIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
|
||||
<path d="M12.75 12.75a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM7.5 15.75a.75.75 0 100-1.5.75.75 0 000 1.5zM8.25 17.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM9.75 15.75a.75.75 0 100-1.5.75.75 0 000 1.5zM10.5 17.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM12 15.75a.75.75 0 100-1.5.75.75 0 000 1.5zM12.75 17.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM14.25 15.75a.75.75 0 100-1.5.75.75 0 000 1.5zM15 17.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM16.5 15.75a.75.75 0 100-1.5.75.75 0 000 1.5zM15 12.75a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM16.5 13.5a.75.75 0 100-1.5.75.75 0 000 1.5z" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M6.75 2.25A.75.75 0 017.5 3v1.5h9V3A.75.75 0 0118 3v1.5h.75a3 3 0 013 3v11.25a3 3 0 01-3 3H5.25a3 3 0 01-3-3V7.5a3 3 0 013-3H6V3a.75.75 0 01.75-.75zm13.5 9a1.5 1.5 0 00-1.5-1.5H5.25a1.5 1.5 0 00-1.5 1.5v7.5a1.5 1.5 0 001.5 1.5h13.5a1.5 1.5 0 001.5-1.5v-7.5z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function Question({ defaultDate, format }: { defaultDate?: Date; format?: string }) {
|
||||
const [datePickerOpen, setDatePickerOpen] = useState(false);
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(defaultDate);
|
||||
const [hideInvalid, setHideInvalid] = useState(!selectedDate);
|
||||
|
||||
useEffect(() => {
|
||||
if (datePickerOpen) {
|
||||
const input = document.querySelector(".react-date-picker__inputGroup__input") as HTMLInputElement;
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
}, [datePickerOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!!selectedDate) {
|
||||
if (hideInvalid) {
|
||||
setHideInvalid(false);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedDate]);
|
||||
|
||||
const formattedDate = useMemo(() => {
|
||||
if (!selectedDate) return "";
|
||||
|
||||
if (format === "M-d-y") {
|
||||
return `${selectedDate?.getMonth() + 1}-${selectedDate?.getDate()}-${selectedDate?.getFullYear()}`;
|
||||
}
|
||||
|
||||
if (format === "d-M-y") {
|
||||
return `${selectedDate?.getDate()}-${selectedDate?.getMonth() + 1}-${selectedDate?.getFullYear()}`;
|
||||
}
|
||||
|
||||
return `${selectedDate?.getFullYear()}-${selectedDate?.getMonth() + 1}-${selectedDate?.getDate()}`;
|
||||
}, [format, selectedDate]);
|
||||
|
||||
return (
|
||||
<div className="relative h-12">
|
||||
{!datePickerOpen && (
|
||||
<div
|
||||
onClick={() => setDatePickerOpen(true)}
|
||||
className="relative flex h-12 w-full cursor-pointer appearance-none items-center justify-center rounded-lg border border-slate-300 bg-white text-left text-base font-normal text-slate-900 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon />
|
||||
<span>{selectedDate ? formattedDate : "Select a date"}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* @ts-expect-error */}
|
||||
<DatePicker
|
||||
key={datePickerOpen}
|
||||
value={selectedDate}
|
||||
isOpen={datePickerOpen}
|
||||
onChange={(value) => {
|
||||
const event = new CustomEvent("dateChange", { detail: value });
|
||||
setSelectedDate(value as Date);
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
minDate={new Date(new Date().getFullYear() - 100, new Date().getMonth(), new Date().getDate())}
|
||||
maxDate={new Date("3000-12-31")}
|
||||
dayPlaceholder="DD"
|
||||
monthPlaceholder="MM"
|
||||
yearPlaceholder="YYYY"
|
||||
format={format ?? "M-d-y"}
|
||||
className={`dp-input-root rounded-lg ${!datePickerOpen ? "wrapper-hide" : ""}
|
||||
${hideInvalid ? "hide-invalid" : ""}
|
||||
`}
|
||||
calendarClassName="calendar-root w-80 rounded-lg border border-[#e5e7eb] p-3 shadow-md"
|
||||
clearIcon={null}
|
||||
onCalendarOpen={() => {
|
||||
setDatePickerOpen(true);
|
||||
}}
|
||||
onCalendarClose={() => {
|
||||
// reset state
|
||||
setDatePickerOpen(false);
|
||||
setSelectedDate(selectedDate);
|
||||
}}
|
||||
// @ts-ignore
|
||||
calendarIcon={<CalendarIcon />}
|
||||
tileClassName={({ date }) => {
|
||||
const baseClass =
|
||||
"hover:bg-slate-200 rounded-md h-9 p-0 mt-1 font-normal text-slate-900 aria-selected:opacity-100";
|
||||
// today's date class
|
||||
if (
|
||||
date.getDate() === new Date().getDate() &&
|
||||
date.getMonth() === new Date().getMonth() &&
|
||||
date.getFullYear() === new Date().getFullYear()
|
||||
) {
|
||||
return `${baseClass} bg-slate-100`;
|
||||
}
|
||||
// active date class
|
||||
if (
|
||||
date.getDate() === selectedDate?.getDate() &&
|
||||
date.getMonth() === selectedDate?.getMonth() &&
|
||||
date.getFullYear() === selectedDate?.getFullYear()
|
||||
) {
|
||||
return `${baseClass} !bg-slate-900 !text-slate-100`;
|
||||
}
|
||||
|
||||
return baseClass;
|
||||
}}
|
||||
formatShortWeekday={(_, date) => {
|
||||
return date.toLocaleDateString("en-US", { weekday: "short" }).slice(0, 2);
|
||||
}}
|
||||
showNeighboringMonth={false}
|
||||
showLeadingZeros={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
packages/surveys/src/sideload/question-date/index.tsx
Normal file
31
packages/surveys/src/sideload/question-date/index.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { render } from "preact";
|
||||
import Question from "./Question.tsx";
|
||||
import globalCss from "./styles/globals.css?inline";
|
||||
import calendarCss from "react-calendar/dist/Calendar.css?inline";
|
||||
import datePickerCss from "react-date-picker/dist/DatePicker.css?inline";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
initDatePicker: (element: HTMLElement, selectedDate?: Date) => void;
|
||||
selectedDate: Date;
|
||||
}
|
||||
}
|
||||
|
||||
const addStylesToDom = () => {
|
||||
if (document.getElementById("formbricks__question_date_css") === null) {
|
||||
const styleElement = document.createElement("style");
|
||||
styleElement.id = "formbricks__question_date_css";
|
||||
styleElement.innerHTML = globalCss + datePickerCss + calendarCss;
|
||||
document.head.appendChild(styleElement);
|
||||
}
|
||||
};
|
||||
|
||||
const init = (element: HTMLElement, selectedDate?: Date, format?: string) => {
|
||||
addStylesToDom();
|
||||
const container = document.createElement("div");
|
||||
container.id = "datePickerContainer";
|
||||
element.appendChild(container);
|
||||
render(<Question defaultDate={selectedDate} format={format} />, container);
|
||||
};
|
||||
|
||||
window.initDatePicker = init;
|
||||
@@ -0,0 +1,75 @@
|
||||
.dp-input-root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dp-input-root [class$="wrapper"] {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgb(203 213 225) !important;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dp-input-root [class$="inputGroup"] {
|
||||
flex: none;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.wrapper-hide .react-date-picker__inputGroup {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dp-input-root .react-date-picker__inputGroup__input {
|
||||
background: #f1f5f9 !important;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.dp-input-root .react-date-picker__inputGroup__input:invalid {
|
||||
background: #fecaca !important;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.hide-invalid .react-date-picker__inputGroup__input:invalid {
|
||||
background: #f1f5f9 !important;
|
||||
}
|
||||
|
||||
.wrapper-hide .react-date-picker__wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calendar-root [class$="navigation"] {
|
||||
height: 36px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.calendar-root [class$="navigation"] button {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.calendar-root [class$="navigation"] button:hover {
|
||||
background: rgb(226 232 240) !important;
|
||||
}
|
||||
|
||||
.calendar-root [class$="navigation"] [class$="navigation__label"] {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.react-calendar__month-view__weekdays__weekday {
|
||||
color: rgb(100, 116, 139);
|
||||
font-weight: 400;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.react-calendar__month-view__weekdays__weekday > abbr {
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"baseUrl": ".",
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
import { resolve } from "path";
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import preact from "@preact/preset-vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
build: {
|
||||
emptyOutDir: false, // keep the dist folder to avoid errors with pnpm go when folder is empty during build
|
||||
minify: "terser",
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
// Could also be a dictionary or array of multiple entry points
|
||||
entry: resolve(__dirname, "src/index.ts"),
|
||||
name: "formbricks-surveys",
|
||||
formats: ["cjs", "es", "umd"],
|
||||
// the proper extensions will be added
|
||||
fileName: "index",
|
||||
const buildPackage = process.env.SURVEYS_PACKAGE_BUILD || "surveys";
|
||||
|
||||
const entryPoint = buildPackage === "surveys" ? "src/index.ts" : "src/sideload/question-date/index.tsx";
|
||||
const name = buildPackage === "surveys" ? "formbricks-surveys" : "formbricks-question-date";
|
||||
const fileName = buildPackage === "surveys" ? "index" : "question-date";
|
||||
|
||||
const config = ({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
|
||||
return defineConfig({
|
||||
define: {
|
||||
"process.env": env,
|
||||
},
|
||||
},
|
||||
plugins: [preact(), dts({ rollupTypes: true }), tsconfigPaths()],
|
||||
});
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
minify: "terser",
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: resolve(__dirname, entryPoint),
|
||||
name,
|
||||
formats: ["cjs", "es", "umd"],
|
||||
fileName,
|
||||
},
|
||||
},
|
||||
plugins: [preact(), dts({ rollupTypes: true }), tsconfigPaths()],
|
||||
});
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -18,6 +18,7 @@ export enum TSurveyQuestionType {
|
||||
Rating = "rating",
|
||||
Consent = "consent",
|
||||
PictureSelection = "pictureSelection",
|
||||
Date = "date",
|
||||
}
|
||||
|
||||
export const ZSurveyWelcomeCard = z.object({
|
||||
@@ -326,6 +327,14 @@ export const ZSurveyRatingQuestion = ZSurveyQuestionBase.extend({
|
||||
logic: z.array(ZSurveyRatingLogic).optional(),
|
||||
});
|
||||
|
||||
export const ZSurveyDateQuestion = ZSurveyQuestionBase.extend({
|
||||
type: z.literal(TSurveyQuestionType.Date),
|
||||
html: z.string().optional(),
|
||||
format: z.enum(["M-d-y", "d-M-y", "y-M-d"]),
|
||||
});
|
||||
|
||||
export type TSurveyDateQuestion = z.infer<typeof ZSurveyDateQuestion>;
|
||||
|
||||
export type TSurveyRatingQuestion = z.infer<typeof ZSurveyRatingQuestion>;
|
||||
|
||||
export const ZSurveyPictureSelectionQuestion = ZSurveyQuestionBase.extend({
|
||||
@@ -346,6 +355,7 @@ export const ZSurveyQuestion = z.union([
|
||||
ZSurveyCTAQuestion,
|
||||
ZSurveyRatingQuestion,
|
||||
ZSurveyPictureSelectionQuestion,
|
||||
ZSurveyDateQuestion,
|
||||
ZSurveyFileUploadQuestion,
|
||||
]);
|
||||
|
||||
@@ -443,6 +453,7 @@ export const ZSurveyTSurveyQuestionType = z.union([
|
||||
z.literal("rating"),
|
||||
z.literal("consent"),
|
||||
z.literal("pictureSelection"),
|
||||
z.literal("date"),
|
||||
]);
|
||||
|
||||
export type TSurveyTSurveyQuestionType = z.infer<typeof ZSurveyTSurveyQuestionType>;
|
||||
|
||||
@@ -1,36 +1,22 @@
|
||||
import {
|
||||
ChatBubbleBottomCenterTextIcon,
|
||||
EnvelopeIcon,
|
||||
HashtagIcon,
|
||||
LinkIcon,
|
||||
PhoneIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import React from "react";
|
||||
|
||||
interface TSurveyQuestionType {
|
||||
interface TOption {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface QuestionTypeSelectorProps {
|
||||
questionTypes: TSurveyQuestionType[];
|
||||
currentType: string | undefined;
|
||||
options: TOption[];
|
||||
currentOption: string | undefined;
|
||||
handleTypeChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const typeIcons: { [key: string]: React.ReactNode } = {
|
||||
text: <ChatBubbleBottomCenterTextIcon />,
|
||||
email: <EnvelopeIcon />,
|
||||
url: <LinkIcon />,
|
||||
number: <HashtagIcon />,
|
||||
phone: <PhoneIcon />,
|
||||
};
|
||||
|
||||
export function QuestionTypeSelector({
|
||||
questionTypes,
|
||||
currentType,
|
||||
export function OptionsSwitcher({
|
||||
options: questionTypes,
|
||||
currentOption,
|
||||
handleTypeChange,
|
||||
}: QuestionTypeSelectorProps): JSX.Element {
|
||||
}: QuestionTypeSelectorProps) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between rounded-md border p-1">
|
||||
{questionTypes.map((type) => (
|
||||
@@ -38,13 +24,15 @@ export function QuestionTypeSelector({
|
||||
key={type.value}
|
||||
onClick={() => handleTypeChange(type.value)}
|
||||
className={`flex-grow cursor-pointer rounded-md bg-${
|
||||
(currentType === undefined && type.value === "text") || currentType === type.value
|
||||
(currentOption === undefined && type.value === "text") || currentOption === type.value
|
||||
? "slate-100"
|
||||
: "white"
|
||||
} p-2 text-center`}>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<span className="text-sm text-slate-900">{type.label}</span>
|
||||
<div className="h-4 w-4 text-slate-600 hover:text-slate-800">{typeIcons[type.value]}</div>
|
||||
{type.icon ? (
|
||||
<div className="h-4 w-4 text-slate-600 hover:text-slate-800">{type.icon}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { FileUploadResponse } from "../FileUploadResponse";
|
||||
import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { LoadingWrapper } from "../LoadingWrapper";
|
||||
import { formatDateWithOrdinal } from "@formbricks/lib/utils/datetime";
|
||||
|
||||
export interface SingleResponseCardProps {
|
||||
survey: TSurvey;
|
||||
@@ -61,6 +62,13 @@ function TooltipRenderer(props: TooltipRendererProps) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function DateResponse({ date }: { date?: string }) {
|
||||
if (!date) return null;
|
||||
|
||||
const formattedDateString = formatDateWithOrdinal(new Date(date));
|
||||
return <p className="ph-no-capture my-1 font-semibold text-slate-700">{formattedDateString}</p>;
|
||||
}
|
||||
|
||||
export default function SingleResponseCard({
|
||||
survey,
|
||||
response,
|
||||
@@ -321,6 +329,8 @@ export default function SingleResponseCard({
|
||||
range={question.range}
|
||||
/>
|
||||
</div>
|
||||
) : question.type === TSurveyQuestionType.Date ? (
|
||||
<DateResponse date={response.data[question.id] as string} />
|
||||
) : (
|
||||
<p className="ph-no-capture my-1 font-semibold text-slate-700">
|
||||
{response.data[question.id]}
|
||||
|
||||
621
pnpm-lock.yaml
generated
621
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
72
turbo.json
72
turbo.json
@@ -46,14 +46,20 @@
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**", ".next/**"],
|
||||
"env": [
|
||||
"AIRTABLE_CLIENT_ID",
|
||||
"ASSET_PREFIX_URL",
|
||||
"AWS_ACCESS_KEY",
|
||||
"AWS_SECRET_KEY",
|
||||
"AZUREAD_AUTH_ENABLED",
|
||||
"AZUREAD_CLIENT_ID",
|
||||
"AZUREAD_CLIENT_SECRET",
|
||||
"AZUREAD_TENANT_ID",
|
||||
"CRON_SECRET",
|
||||
"ENCRYPTION_KEY",
|
||||
"DEBUG",
|
||||
"EMAIL_VERIFICATION_DISABLED",
|
||||
"ENCRYPTION_KEY",
|
||||
"ENTERPRISE_LICENSE_KEY",
|
||||
"FORMBRICKS_ENCRYPTION_KEY",
|
||||
"GITHUB_ID",
|
||||
"GITHUB_SECRET",
|
||||
"GOOGLE_CLIENT_ID",
|
||||
@@ -62,65 +68,55 @@
|
||||
"GOOGLE_SHEETS_CLIENT_SECRET",
|
||||
"GOOGLE_SHEETS_REDIRECT_URL",
|
||||
"HEROKU_APP_NAME",
|
||||
"IMPRINT_URL",
|
||||
"INSTANCE_ID",
|
||||
"INTERNAL_SECRET",
|
||||
"MAIL_FROM",
|
||||
"EMAIL_VERIFICATION_DISABLED",
|
||||
"FORMBRICKS_ENCRYPTION_KEY",
|
||||
"INVITE_DISABLED",
|
||||
"IS_FORMBRICKS_CLOUD",
|
||||
"GOOGLE_AUTH_ENABLED",
|
||||
"GITHUB_AUTH_ENABLED",
|
||||
"IS_FORMBRICKS_CLOUD",
|
||||
"PASSWORD_RESET_DISABLED",
|
||||
"PRIVACY_URL",
|
||||
"NEXT_PUBLIC_SENTRY_DSN",
|
||||
"INVITE_DISABLED",
|
||||
"SIGNUP_DISABLED",
|
||||
"NEXT_PUBLIC_STRIPE_PRICING_TABLE_ID",
|
||||
"TERMS_URL",
|
||||
"NEXTAUTH_SECRET",
|
||||
"NEXTAUTH_URL",
|
||||
"SMTP_HOST",
|
||||
"SMTP_PASSWORD",
|
||||
"SMTP_PORT",
|
||||
"SMTP_SECURE_ENABLED",
|
||||
"SMTP_USER",
|
||||
"MAIL_FROM",
|
||||
"NEXT_PUBLIC_DOCSEARCH_APP_ID",
|
||||
"NEXT_PUBLIC_DOCSEARCH_API_KEY",
|
||||
"NEXT_PUBLIC_DOCSEARCH_INDEX_NAME",
|
||||
"NEXT_PUBLIC_FORMBRICKS_API_HOST",
|
||||
"NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID",
|
||||
"NEXT_PUBLIC_FORMBRICKS_FORM_ID",
|
||||
"NEXT_PUBLIC_FORMBRICKS_FEEDBACK_FORM_ID",
|
||||
"NEXT_PUBLIC_FORMBRICKS_FEEDBACK_CUSTOM_FORM_ID",
|
||||
"NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID",
|
||||
"NEXT_PUBLIC_FORMBRICKS_PMF_FORM_ID",
|
||||
"NEXT_PUBLIC_FORMBRICKS_URL",
|
||||
"IMPRINT_URL",
|
||||
"NEXT_PUBLIC_SENTRY_DSN",
|
||||
"SHORT_URL_BASE",
|
||||
"NODE_ENV",
|
||||
"NEXT_PUBLIC_POSTHOG_API_HOST",
|
||||
"NEXT_PUBLIC_POSTHOG_API_KEY",
|
||||
"NEXT_PUBLIC_FORMBRICKS_COM_API_HOST",
|
||||
"NEXT_PUBLIC_FORMBRICKS_COM_ENVIRONMENT_ID",
|
||||
"NEXT_PUBLIC_FORMBRICKS_COM_DOCS_FEEDBACK_SURVEY_ID",
|
||||
"WEBAPP_URL",
|
||||
"SENTRY_DSN",
|
||||
"STRAPI_API_KEY",
|
||||
"STRIPE_SECRET_KEY",
|
||||
"STRIPE_WEBHOOK_SECRET",
|
||||
"TELEMETRY_DISABLED",
|
||||
"VERCEL_URL",
|
||||
"WEBAPP_URL",
|
||||
"AIRTABLE_CLIENT_ID",
|
||||
"AWS_ACCESS_KEY",
|
||||
"AWS_SECRET_KEY",
|
||||
"NEXTAUTH_SECRET",
|
||||
"NEXTAUTH_URL",
|
||||
"NODE_ENV",
|
||||
"PASSWORD_RESET_DISABLED",
|
||||
"PLAYWRIGHT_CI",
|
||||
"PRIVACY_URL",
|
||||
"S3_ACCESS_KEY",
|
||||
"S3_SECRET_KEY",
|
||||
"S3_REGION",
|
||||
"S3_BUCKET_NAME",
|
||||
"ENTERPRISE_LICENSE_KEY",
|
||||
"PLAYWRIGHT_CI"
|
||||
"SENTRY_DSN",
|
||||
"SHORT_URL_BASE",
|
||||
"SIGNUP_DISABLED",
|
||||
"SMTP_HOST",
|
||||
"SMTP_PASSWORD",
|
||||
"SMTP_PORT",
|
||||
"SMTP_SECURE_ENABLED",
|
||||
"SMTP_USER",
|
||||
"STRAPI_API_KEY",
|
||||
"STRIPE_SECRET_KEY",
|
||||
"STRIPE_WEBHOOK_SECRET",
|
||||
"SURVEYS_PACKAGE_MODE",
|
||||
"SURVEYS_PACKAGE_BUILD",
|
||||
"TELEMETRY_DISABLED",
|
||||
"TERMS_URL",
|
||||
"VERCEL_URL",
|
||||
"WEBAPP_URL"
|
||||
]
|
||||
},
|
||||
"post-install": {
|
||||
|
||||
Reference in New Issue
Block a user