feat: adds vercel style guide in surveys package WIP (#4401)

This commit is contained in:
Piyush Gupta
2024-12-09 14:30:14 +05:30
committed by GitHub
parent dfe025ab8e
commit 88357a3aeb
56 changed files with 981 additions and 887 deletions

View File

@@ -190,7 +190,8 @@ const Page = async (props) => {
<Link
href="https://app.formbricks.com/s/clvupq3y205i5yrm3sm9v1xt5"
target="_blank"
rel="noopener noreferrer">
rel="noopener noreferrer nofollow"
referrerPolicy="no-referrer">
{t("environments.settings.enterprise.request_30_day_trial_license")}
</Link>
</Button>

View File

@@ -20,7 +20,8 @@ import {
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { SignupForm } from "./components/signup-form";
export const SignupPage = async ({ searchParams }) => {
export const SignupPage = async ({ searchParams: searchParamsProps }) => {
const searchParams = await searchParamsProps;
const inviteToken = searchParams["inviteToken"] ?? null;
const [isMultOrgEnabled, isSSOEnabled] = await Promise.all([getIsMultiOrgEnabled(), getIsSSOEnabled()]);
const locale = await findMatchingLocale();

View File

@@ -3,9 +3,9 @@
"license": "MIT",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Formbricks-surveys is a helper library to embed surveys into your application.",
"homepage": "https://formbricks.com",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/formbricks/formbricks"
@@ -42,6 +42,7 @@
"@formbricks/lib": "workspace:*",
"@formbricks/types": "workspace:*",
"@preact/preset-vite": "2.9.0",
"@types/react": "18.3.11",
"autoprefixer": "10.4.20",
"concurrently": "8.2.2",
"isomorphic-dompurify": "2.14.0",
@@ -56,6 +57,7 @@
"vite-tsconfig-paths": "5.0.1"
},
"dependencies": {
"@formkit/auto-animate": "0.8.2"
"@formkit/auto-animate": "0.8.2",
"react-calendar": "5.1.0"
}
}

View File

@@ -6,17 +6,17 @@ interface BackButtonProps {
tabIndex?: number;
}
export const BackButton = ({ onClick, backButtonLabel, tabIndex = 2 }: BackButtonProps) => {
export function BackButton({ onClick, backButtonLabel, tabIndex = 2 }: BackButtonProps) {
return (
<button
dir="auto"
tabIndex={tabIndex}
type={"button"}
type="button"
className={cn(
"fb-border-back-button-border fb-text-heading focus:fb-ring-focus fb-rounded-custom fb-flex fb-items-center fb-border fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 fb-shadow-sm hover:fb-opacity-90 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2"
)}
onClick={onClick}>
{backButtonLabel || "Back"}
{backButtonLabel ?? "Back"}
</button>
);
};
}

View File

@@ -1,4 +1,4 @@
import { JSX } from "preact";
import { type JSX } from "preact";
import { useCallback } from "preact/hooks";
interface SubmitButtonProps extends JSX.HTMLAttributes<HTMLButtonElement> {
@@ -7,7 +7,7 @@ interface SubmitButtonProps extends JSX.HTMLAttributes<HTMLButtonElement> {
focus?: boolean;
}
export const SubmitButton = ({
export function SubmitButton({
buttonLabel,
isLastQuestion,
tabIndex = 1,
@@ -16,7 +16,7 @@ export const SubmitButton = ({
disabled,
type,
...props
}: SubmitButtonProps) => {
}: SubmitButtonProps) {
const buttonRef = useCallback(
(currentButton: HTMLButtonElement | null) => {
if (currentButton && focus) {
@@ -42,4 +42,4 @@ export const SubmitButton = ({
{buttonLabel || (isLastQuestion ? "Finish" : "Next")}
</button>
);
};
}

View File

@@ -1,71 +0,0 @@
import { GlobeIcon } from "@/components/general/GlobeIcon";
import { useRef, useState } from "react";
import { getLanguageLabel } from "@formbricks/lib/i18n/utils";
import { useClickOutside } from "@formbricks/lib/utils/hooks/useClickOutside";
import { TSurveyLanguage } from "@formbricks/types/surveys/types";
interface LanguageSwitchProps {
surveyLanguages: TSurveyLanguage[];
setSelectedLanguageCode: (languageCode: string) => void;
setFirstRender?: (firstRender: boolean) => void;
}
export const LanguageSwitch = ({
surveyLanguages,
setSelectedLanguageCode,
setFirstRender,
}: LanguageSwitchProps) => {
const [showLanguageDropdown, setShowLanguageDropdown] = useState(false);
const toggleDropdown = () => setShowLanguageDropdown((prev) => !prev);
const languageDropdownRef = useRef(null);
const defaultLanguageCode = surveyLanguages.find((surveyLanguage) => {
return surveyLanguage.default === true;
})?.language.code;
const changeLanguage = (languageCode: string) => {
if (languageCode === defaultLanguageCode) {
setSelectedLanguageCode("default");
} else {
setSelectedLanguageCode(languageCode);
}
if (setFirstRender) {
//for lexical editor
setFirstRender(true);
}
setShowLanguageDropdown(false);
};
useClickOutside(languageDropdownRef, () => setShowLanguageDropdown(false));
return (
<div class="fb-z-[1001] fb-flex fb-w-fit fb-items-center even:fb-pr-1">
<button
title="Language switch"
type="button"
class="fb-text-heading fb-relative fb-h-5 fb-w-5 fb-rounded-md hover:fb-bg-black/5 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2"
onClick={toggleDropdown}
tabIndex={-1}
aria-haspopup="true"
aria-expanded={showLanguageDropdown}>
<GlobeIcon className="fb-text-heading fb-h-5 fb-w-5 fb-p-0.5" />
</button>
{showLanguageDropdown && (
<div
className="fb-bg-brand fb-text-on-brand fb-absolute fb-right-8 fb-top-10 fb-space-y-2 fb-rounded-md fb-p-2 fb-text-xs"
ref={languageDropdownRef}>
{surveyLanguages.map((surveyLanguage) => {
if (!surveyLanguage.enabled) return;
return (
<button
key={surveyLanguage.language.id}
type="button"
className="fb-block fb-w-full fb-p-1.5 fb-text-left hover:fb-opacity-80"
onClick={() => changeLanguage(surveyLanguage.language.code)}>
{getLanguageLabel(surveyLanguage.language.code, "en-US")}
</button>
);
})}
</div>
)}
</div>
);
};

View File

@@ -1,44 +0,0 @@
import { SubmitButton } from "@/components/buttons/SubmitButton";
import { processResponseData } from "@formbricks/lib/responses";
import { type TResponseData } from "@formbricks/types/responses";
import { type TSurveyQuestion } from "@formbricks/types/surveys/types";
type ResponseErrorComponentProps = {
questions: TSurveyQuestion[];
responseData: TResponseData;
onRetry: () => void;
};
export const ResponseErrorComponent = ({ questions, responseData, onRetry }: ResponseErrorComponentProps) => {
return (
<div className={"fb-flex fb-flex-col fb-bg-white fb-p-4"}>
<span className={"fb-mb-1.5 fb-text-base fb-font-bold fb-leading-6 fb-text-slate-900"}>
{"Your feedback is stuck :("}
</span>
<p className={"fb-max-w-md fb-text-sm fb-font-normal fb-leading-6 fb-text-slate-600"}>
The servers cannot be reached at the moment.
<br />
Please retry now or try again later.
</p>
<div className={"fb-mt-4 fb-rounded-lg fb-border fb-border-slate-200 fb-bg-slate-100 fb-px-4 fb-py-5"}>
<div className={"fb-flex fb-max-h-36 fb-flex-1 fb-flex-col fb-space-y-3 fb-overflow-y-scroll"}>
{questions.map((question, index) => {
const response = responseData[question.id];
if (!response) return;
return (
<div className={"fb-flex fb-flex-col"}>
<span className={"fb-text-sm fb-leading-6 fb-text-slate-900"}>{`Question ${index + 1}`}</span>
<span className={"fb-mt-1 fb-text-sm fb-font-semibold fb-leading-6 fb-text-slate-900"}>
{processResponseData(response)}
</span>
</div>
);
})}
</div>
</div>
<div className={"fb-mt-4 fb-flex fb-flex-1 fb-flex-row fb-items-center fb-justify-end fb-space-x-2"}>
<SubmitButton tabIndex={2} buttonLabel="Retry" isLastQuestion={false} onClick={() => onRetry()} />
</div>
</div>
);
};

View File

@@ -2,15 +2,15 @@ interface AutoCloseProgressBarProps {
autoCloseTimeout: number;
}
export const AutoCloseProgressBar = ({ autoCloseTimeout }: AutoCloseProgressBarProps) => {
export function AutoCloseProgressBar({ autoCloseTimeout }: AutoCloseProgressBarProps) {
return (
<div className="fb-bg-accent-bg fb-h-2 fb-w-full fb-overflow-hidden fb-rounded-full">
<div
key={autoCloseTimeout}
className="fb-bg-brand fb-z-20 fb-h-2 fb-rounded-full"
style={{
animation: `shrink-width-to-zero ${autoCloseTimeout}s linear forwards`,
}}></div>
animation: `shrink-width-to-zero ${autoCloseTimeout.toString()}s linear forwards`,
}} />
</div>
);
};
}

View File

@@ -1,14 +1,14 @@
import { cn } from "@/lib/utils";
import snippet from "@calcom/embed-snippet";
import { useEffect, useMemo } from "preact/hooks";
import { TSurveyCalQuestion } from "@formbricks/types/surveys/types";
import { type TSurveyCalQuestion } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/utils";
interface CalEmbedProps {
question: TSurveyCalQuestion;
onSuccessfulBooking: () => void;
}
export const CalEmbed = ({ question, onSuccessfulBooking }: CalEmbedProps) => {
export function CalEmbed({ question, onSuccessfulBooking }: CalEmbedProps) {
const cal = useMemo(() => {
const calInline = snippet("https://cal.com/embed.js");
@@ -43,7 +43,7 @@ export const CalEmbed = ({ question, onSuccessfulBooking }: CalEmbedProps) => {
useEffect(() => {
// remove any existing cal-inline elements
document.querySelectorAll("cal-inline").forEach((el) => el.remove());
document.querySelectorAll("cal-inline").forEach((el) => { el.remove(); });
cal("init", { calOrigin: question.calHost ? `https://${question.calHost}` : "https://cal.com" });
cal("inline", {
elementOrSelector: "#fb-cal-embed",
@@ -56,4 +56,4 @@ export const CalEmbed = ({ question, onSuccessfulBooking }: CalEmbedProps) => {
<div id="fb-cal-embed" className={cn("fb-border-border fb-rounded-lg fb-border")} />
</div>
);
};
}

View File

@@ -1,15 +1,15 @@
import { SubmitButton } from "@/components/buttons/SubmitButton";
import { Headline } from "@/components/general/Headline";
import { LoadingSpinner } from "@/components/general/LoadingSpinner";
import { QuestionMedia } from "@/components/general/QuestionMedia";
import { Subheader } from "@/components/general/Subheader";
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
import { LoadingSpinner } from "@/components/general/loading-spinner";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { replaceRecallInfo } from "@/lib/recall";
import { useEffect } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
import { TSurveyEndScreenCard, TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types";
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { type TResponseData, type TResponseVariables } from "@formbricks/types/responses";
import { type TSurveyEndScreenCard, type TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types";
interface EndingCardProps {
survey: TJsEnvironmentStateSurvey;
@@ -23,7 +23,7 @@ interface EndingCardProps {
variablesData: TResponseVariables;
}
export const EndingCard = ({
export function EndingCard({
survey,
endingCard,
isRedirectDisabled,
@@ -33,9 +33,9 @@ export const EndingCard = ({
languageCode,
responseData,
variablesData,
}: EndingCardProps) => {
}: EndingCardProps) {
const media =
endingCard.type === "endScreen" && (endingCard.imageUrl || endingCard.videoUrl) ? (
endingCard.type === "endScreen" && (endingCard.imageUrl ?? endingCard.videoUrl) ? (
<QuestionMedia imgUrl={endingCard.imageUrl} videoUrl={endingCard.videoUrl} />
) : null;
const checkmark = (
@@ -46,14 +46,14 @@ export const EndingCard = ({
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
class="fb-h-24 fb-w-24">
className="fb-h-24 fb-w-24">
<path
stroke-linecap="round"
stroke-linejoin="round"
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="fb-bg-brand fb-mb-[10px] fb-inline-block fb-h-1 fb-w-16 fb-rounded-[100%]"></span>
<span className="fb-bg-brand fb-mb-[10px] fb-inline-block fb-h-1 fb-w-16 fb-rounded-[100%]" />
</div>
);
@@ -85,7 +85,7 @@ export const EndingCard = ({
document.removeEventListener("keydown", handleEnter);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps -- we only want to run this effect when isCurrent changes
}, [isCurrent]);
return (
@@ -93,10 +93,10 @@ export const EndingCard = ({
<div className="fb-text-center">
{isResponseSendingFinished ? (
<>
{endingCard.type === "endScreen" && (media || checkmark)}
{endingCard.type === "endScreen" && (media ?? checkmark)}
<div>
<Headline
alignTextCenter={true}
alignTextCenter
headline={
endingCard.type === "endScreen"
? replaceRecallInfo(
@@ -120,7 +120,7 @@ export const EndingCard = ({
}
questionId="EndingCard"
/>
{endingCard.type === "endScreen" && endingCard.buttonLabel && (
{endingCard.type === "endScreen" && endingCard.buttonLabel ? (
<div className="fb-mt-6 fb-flex fb-w-full fb-flex-col fb-items-center fb-justify-center fb-space-y-4">
<SubmitButton
buttonLabel={replaceRecallInfo(
@@ -133,7 +133,7 @@ export const EndingCard = ({
onClick={handleSubmit}
/>
</div>
)}
) : null}
</div>
</>
) : (
@@ -147,4 +147,4 @@ export const EndingCard = ({
</div>
</ScrollableContainer>
);
};
}

View File

@@ -1,11 +1,11 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useMemo, useState } from "preact/hooks";
import { JSXInternal } from "preact/src/jsx";
import { type JSXInternal } from "preact/src/jsx";
import { getOriginalFileNameFromUrl } from "@formbricks/lib/storage/utils";
import { isFulfilled, isRejected } from "@formbricks/lib/utils/promises";
import { TAllowedFileExtension } from "@formbricks/types/common";
import { TJsFileUploadParams } from "@formbricks/types/js";
import { TUploadFileConfig } from "@formbricks/types/storage";
import { type TAllowedFileExtension } from "@formbricks/types/common";
import { type TJsFileUploadParams } from "@formbricks/types/js";
import { type TUploadFileConfig } from "@formbricks/types/storage";
interface FileInputProps {
allowedFileExtensions?: TAllowedFileExtension[];
@@ -20,7 +20,7 @@ interface FileInputProps {
const FILE_LIMIT = 25;
export const FileInput = ({
export function FileInput({
allowedFileExtensions,
surveyId,
onFileUpload,
@@ -29,7 +29,7 @@ export const FileInput = ({
maxSizeInMB,
allowMultipleFiles,
htmlFor = "",
}: FileInputProps) => {
}: FileInputProps) {
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [parent] = useAutoAnimate();
@@ -39,7 +39,7 @@ export const FileInput = ({
const fileBuffer = await file.arrayBuffer();
const bufferKB = fileBuffer.byteLength / 1024;
if (bufferKB > maxSizeInMB * 1024) {
alert(`File should be less than ${maxSizeInMB} MB`);
alert(`File should be less than ${maxSizeInMB.toString()} MB`);
return false;
}
}
@@ -55,7 +55,7 @@ export const FileInput = ({
}
if (allowMultipleFiles && selectedFiles.length + fileArray.length > FILE_LIMIT) {
alert(`You can only upload a maximum of ${FILE_LIMIT} files.`);
alert(`You can only upload a maximum of ${FILE_LIMIT.toString()} files.`);
return;
}
@@ -63,10 +63,9 @@ export const FileInput = ({
const validFiles = Array.from(files).filter((file) => {
const fileExtension = file.type.substring(file.type.lastIndexOf("/") + 1) as TAllowedFileExtension;
if (allowedFileExtensions) {
return allowedFileExtensions?.includes(fileExtension);
} else {
return true;
return allowedFileExtensions.includes(fileExtension);
}
return true;
});
const filteredFiles: File[] = [];
@@ -84,7 +83,9 @@ export const FileInput = ({
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = reject;
});
@@ -122,16 +123,16 @@ export const FileInput = ({
const handleDragOver = (e: JSXInternal.TargetedDragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
// @ts-expect-error
// @ts-expect-error -- TS does not recognize dataTransfer
e.dataTransfer.dropEffect = "copy";
};
const handleDrop = (e: JSXInternal.TargetedDragEvent<HTMLLabelElement>) => {
const handleDrop = async (e: JSXInternal.TargetedDragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
// @ts-expect-error
handleFileSelection(e.dataTransfer.files);
// @ts-expect-error -- TS does not recognize dataTransfer
await handleFileSelection(e.dataTransfer.files);
};
const handleDeleteFile = (index: number, event: JSXInternal.TargetedMouseEvent<SVGSVGElement>) => {
@@ -157,8 +158,7 @@ export const FileInput = ({
const uniqueHtmlFor = useMemo(() => `selectedFile-${htmlFor}`, [htmlFor]);
return (
<div
className={`fb-items-left fb-bg-input-bg hover:fb-bg-input-bg-selected fb-border-border fb-relative fb-mt-3 fb-flex fb-w-full fb-flex-col fb-justify-center fb-rounded-lg fb-border-2 fb-border-dashed dark:fb-border-slate-600 dark:fb-bg-slate-700 dark:hover:fb-border-slate-500 dark:hover:fb-bg-slate-800`}>
<div className="fb-items-left fb-bg-input-bg hover:fb-bg-input-bg-selected fb-border-border fb-relative fb-mt-3 fb-flex fb-w-full fb-flex-col fb-justify-center fb-rounded-lg fb-border-2 fb-border-dashed dark:fb-border-slate-600 dark:fb-bg-slate-700 dark:hover:fb-border-slate-500 dark:hover:fb-bg-slate-800">
<div ref={parent}>
{fileUrls?.map((fileUrl, index) => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
@@ -175,7 +175,9 @@ export const FileInput = ({
strokeWidth={1}
stroke="currentColor"
className="fb-text-heading fb-h-5"
onClick={(e) => handleDeleteFile(index, e)}>
onClick={(e) => {
handleDeleteFile(index, e);
}}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9l10 10m0-10L9 19" />
</svg>
</div>
@@ -205,16 +207,16 @@ export const FileInput = ({
</div>
<div>
{isUploading && (
{isUploading ? (
<div className="fb-inset-0 fb-flex fb-animate-pulse fb-items-center fb-justify-center fb-rounded-lg fb-py-4">
<label htmlFor={uniqueHtmlFor} className="fb-text-subheading fb-text-sm fb-font-medium">
Uploading...
</label>
</div>
)}
) : null}
<label htmlFor={uniqueHtmlFor} onDragOver={handleDragOver} onDrop={handleDrop}>
{showUploader && (
{showUploader ? (
<div
className="focus:fb-outline-brand fb-flex fb-flex-col fb-items-center fb-justify-center fb-py-6 hover:fb-cursor-pointer"
tabIndex={1}
@@ -251,15 +253,15 @@ export const FileInput = ({
onChange={async (e) => {
const inputElement = e.target as HTMLInputElement;
if (inputElement.files) {
handleFileSelection(inputElement.files);
await handleFileSelection(inputElement.files);
}
}}
multiple={allowMultipleFiles}
/>
</div>
)}
) : null}
</label>
</div>
</div>
);
};
}

View File

@@ -1,10 +1,10 @@
export const FormbricksBranding = () => {
export function FormbricksBranding() {
return (
<a
href="https://formbricks.com?utm_source=survey_branding"
target="_blank"
tabIndex={-1}
className="fb-my-2 fb-flex fb-justify-center">
className="fb-my-2 fb-flex fb-justify-center" rel="noopener">
<p className="fb-text-signature fb-text-xs">
Powered by{" "}
<b>
@@ -13,4 +13,4 @@ export const FormbricksBranding = () => {
</p>
</a>
);
};
}

View File

@@ -2,21 +2,20 @@ interface GlobeIconProps {
className?: string;
}
export const GlobeIcon = ({ className }: GlobeIconProps) => {
export function GlobeIcon({ className }: GlobeIconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
className={`lucide lucide-globe ${className ? className.toString() : ""}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-globe">
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>
);
};
}

View File

@@ -1,4 +1,4 @@
import { TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
interface HeadlineProps {
headline?: string;
@@ -6,13 +6,7 @@ interface HeadlineProps {
required?: boolean;
alignTextCenter?: boolean;
}
export const Headline = ({
headline,
questionId,
required = true,
alignTextCenter = false,
}: HeadlineProps) => {
export function Headline({ headline, questionId, required = true, alignTextCenter = false }: HeadlineProps) {
return (
<label
htmlFor={questionId}
@@ -31,4 +25,4 @@ export const Headline = ({
</div>
</label>
);
};
}

View File

@@ -1,20 +1,19 @@
import { cn } from "@/lib/utils";
import DOMPurify from "isomorphic-dompurify";
import { useEffect, useState } from "react";
import { TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
interface HtmlBodyProps {
htmlString?: string;
questionId: TSurveyQuestionId;
}
export const HtmlBody = ({ htmlString, questionId }: HtmlBodyProps) => {
export function HtmlBody({ htmlString, questionId }: HtmlBodyProps) {
const [safeHtml, setSafeHtml] = useState("");
useEffect(() => {
if (htmlString) {
import("isomorphic-dompurify").then((DOMPurify) => {
setSafeHtml(DOMPurify.sanitize(htmlString, { ADD_ATTR: ["target"] }));
});
setSafeHtml(DOMPurify.sanitize(htmlString, { ADD_ATTR: ["target"] }));
}
}, [htmlString]);
@@ -29,4 +28,4 @@ export const HtmlBody = ({ htmlString, questionId }: HtmlBodyProps) => {
dir="auto"
/>
);
};
}

View File

@@ -1,11 +1,9 @@
import { cn } from "@/lib/utils";
import { HTMLAttributes } from "preact/compat";
import { forwardRef } from "preact/compat";
import { type HTMLAttributes, forwardRef } from "preact/compat";
export interface InputProps extends HTMLAttributes<HTMLInputElement> {
className?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => {
return (
<input

View File

@@ -0,0 +1,69 @@
import { useRef, useState } from "preact/hooks";
import { getLanguageLabel } from "@formbricks/lib/i18n/utils";
import { useClickOutside } from "@formbricks/lib/utils/hooks/useClickOutside";
import { type TSurveyLanguage } from "@formbricks/types/surveys/types";
import { GlobeIcon } from "@/components/general/globe-icon";
interface LanguageSwitchProps {
surveyLanguages: TSurveyLanguage[];
setSelectedLanguageCode: (languageCode: string) => void;
setFirstRender?: (firstRender: boolean) => void;
}
export function LanguageSwitch({
surveyLanguages,
setSelectedLanguageCode,
setFirstRender,
}: LanguageSwitchProps) {
const [showLanguageDropdown, setShowLanguageDropdown] = useState(false);
const toggleDropdown = () => { setShowLanguageDropdown((prev) => !prev); };
const languageDropdownRef = useRef(null);
const defaultLanguageCode = surveyLanguages.find((surveyLanguage) => {
return surveyLanguage.default;
})?.language.code;
const changeLanguage = (languageCode: string) => {
if (languageCode === defaultLanguageCode) {
setSelectedLanguageCode("default");
} else {
setSelectedLanguageCode(languageCode);
}
if (setFirstRender) {
//for lexical editor
setFirstRender(true);
}
setShowLanguageDropdown(false);
};
useClickOutside(languageDropdownRef, () => { setShowLanguageDropdown(false); });
return (
<div className="fb-z-[1001] fb-flex fb-w-fit fb-items-center even:fb-pr-1">
<button
title="Language switch"
type="button"
className="fb-text-heading fb-relative fb-h-5 fb-w-5 fb-rounded-md hover:fb-bg-black/5 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2"
onClick={toggleDropdown}
tabIndex={-1}
aria-haspopup="true"
aria-expanded={showLanguageDropdown}>
<GlobeIcon className="fb-text-heading fb-h-5 fb-w-5 fb-p-0.5" />
</button>
{showLanguageDropdown ? <div
className="fb-bg-brand fb-text-on-brand fb-absolute fb-right-8 fb-top-10 fb-space-y-2 fb-rounded-md fb-p-2 fb-text-xs"
ref={languageDropdownRef}>
{surveyLanguages.map((surveyLanguage) => {
if (!surveyLanguage.enabled) return;
return (
<button
key={surveyLanguage.language.id}
type="button"
className="fb-block fb-w-full fb-p-1.5 fb-text-left hover:fb-opacity-80"
onClick={() => { changeLanguage(surveyLanguage.language.code); }}>
{getLanguageLabel(surveyLanguage.language.code, "en-US")}
</button>
);
})}
</div> : null}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { cn } from "@/lib/utils";
export const LoadingSpinner = ({ className }: { className?: string }) => {
export function LoadingSpinner({ className }: { className?: string }) {
return (
<div
data-testid="loading-spinner"
@@ -16,12 +16,12 @@ export const LoadingSpinner = ({ className }: { className?: string }) => {
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"></circle>
strokeWidth="4" />
<path
className="fb-opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
);
};
}

View File

@@ -1,15 +1,15 @@
import { Progress } from "@/components/general/progress";
import { calculateElementIdx } from "@/lib/utils";
import { useCallback, useMemo } from "preact/hooks";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { Progress } from "./Progress";
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
interface ProgressBarProps {
survey: TJsEnvironmentStateSurvey;
questionId: TSurveyQuestionId;
}
export const ProgressBar = ({ survey, questionId }: ProgressBarProps) => {
export function ProgressBar({ survey, questionId }: ProgressBarProps) {
const currentQuestionIdx = useMemo(
() => survey.questions.findIndex((q) => q.id === questionId),
[survey, questionId]
@@ -18,10 +18,12 @@ export const ProgressBar = ({ survey, questionId }: ProgressBarProps) => {
const calculateProgress = useCallback(
(index: number, questionsLength: number) => {
if (questionsLength === 0) return 0;
if (index === -1) index = 0;
let idx = index;
const elementIdx = calculateElementIdx(survey, index);
if (questionsLength === 0) return 0;
if (index === -1) idx = 0;
const elementIdx = calculateElementIdx(survey, idx);
return elementIdx / questionsLength;
},
[survey]
@@ -36,10 +38,9 @@ export const ProgressBar = ({ survey, questionId }: ProgressBarProps) => {
return 0;
} else if (endingCardIds.includes(questionId)) {
return 1;
} else {
return progressArray[currentQuestionIdx];
}
return progressArray[currentQuestionIdx];
}, [questionId, endingCardIds, progressArray, currentQuestionIdx]);
return <Progress progress={progressValue} />;
};
}

View File

@@ -1,9 +1,10 @@
export const Progress = ({ progress }: { progress: number }) => {
export function Progress({ progress }: { progress: number }) {
return (
<div className="fb-bg-accent-bg fb-h-2 fb-w-full fb-overflow-hidden fb-rounded-full">
<div
className="fb-transition-width fb-bg-brand fb-z-20 fb-h-2 fb-rounded-full fb-duration-500"
style={{ width: `${Math.floor(progress * 100)}%` }}></div>
style={{ width: `${Math.floor(progress * 100).toString()}%` }}
/>
</div>
);
};
}

View File

@@ -1,26 +1,26 @@
import { AddressQuestion } from "@/components/questions/AddressQuestion";
import { CTAQuestion } from "@/components/questions/CTAQuestion";
import { CalQuestion } from "@/components/questions/CalQuestion";
import { ConsentQuestion } from "@/components/questions/ConsentQuestion";
import { ContactInfoQuestion } from "@/components/questions/ContactInfoQuestion";
import { DateQuestion } from "@/components/questions/DateQuestion";
import { FileUploadQuestion } from "@/components/questions/FileUploadQuestion";
import { MatrixQuestion } from "@/components/questions/MatrixQuestion";
import { MultipleChoiceMultiQuestion } from "@/components/questions/MultipleChoiceMultiQuestion";
import { MultipleChoiceSingleQuestion } from "@/components/questions/MultipleChoiceSingleQuestion";
import { NPSQuestion } from "@/components/questions/NPSQuestion";
import { OpenTextQuestion } from "@/components/questions/OpenTextQuestion";
import { PictureSelectionQuestion } from "@/components/questions/PictureSelectionQuestion";
import { RankingQuestion } from "@/components/questions/RankingQuestion";
import { RatingQuestion } from "@/components/questions/RatingQuestion";
import { AddressQuestion } from "@/components/questions/address-question";
import { CalQuestion } from "@/components/questions/cal-question";
import { ConsentQuestion } from "@/components/questions/consent-question";
import { ContactInfoQuestion } from "@/components/questions/contact-info-question";
import { CTAQuestion } from "@/components/questions/cta-question";
import { DateQuestion } from "@/components/questions/date-question";
import { FileUploadQuestion } from "@/components/questions/file-upload-question";
import { MatrixQuestion } from "@/components/questions/matrix-question";
import { MultipleChoiceMultiQuestion } from "@/components/questions/multiple-choice-multi-question";
import { MultipleChoiceSingleQuestion } from "@/components/questions/multiple-choice-single-question";
import { NPSQuestion } from "@/components/questions/nps-question";
import { OpenTextQuestion } from "@/components/questions/open-text-question";
import { PictureSelectionQuestion } from "@/components/questions/picture-selection-question";
import { RankingQuestion } from "@/components/questions/ranking-question";
import { RatingQuestion } from "@/components/questions/rating-question";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TJsFileUploadParams } from "@formbricks/types/js";
import { TResponseData, TResponseDataValue, TResponseTtc } from "@formbricks/types/responses";
import { TUploadFileConfig } from "@formbricks/types/storage";
import { type TJsFileUploadParams } from "@formbricks/types/js";
import { type TResponseData, type TResponseDataValue, type TResponseTtc } from "@formbricks/types/responses";
import { type TUploadFileConfig } from "@formbricks/types/storage";
import {
TSurveyQuestion,
TSurveyQuestionChoice,
TSurveyQuestionId,
type TSurveyQuestion,
type TSurveyQuestionChoice,
type TSurveyQuestionId,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
@@ -43,7 +43,7 @@ interface QuestionConditionalProps {
currentQuestionId: TSurveyQuestionId;
}
export const QuestionConditional = ({
export function QuestionConditional({
question,
value,
onChange,
@@ -60,7 +60,7 @@ export const QuestionConditional = ({
onFileUpload,
autoFocusEnabled,
currentQuestionId,
}: QuestionConditionalProps) => {
}: QuestionConditionalProps) {
const getResponseValueForRankingQuestion = (
value: string[],
choices: TSurveyQuestionChoice[]
@@ -316,4 +316,4 @@ export const QuestionConditional = ({
autoFocusEnabled={autoFocusEnabled}
/>
) : null;
};
}

View File

@@ -17,7 +17,7 @@ const getVideoUrlWithParams = (videoUrl: string): string => {
"?title=false&transcript=false&speed=false&quality_selector=false&progress_bar=false&pip=false&fullscreen=false&cc=false&chromecast=false"
);
else if (isLoomUrl) return videoUrl.concat("?hide_share=true&hideEmbedTopBar=true&hide_title=true");
else return videoUrl;
return videoUrl;
};
interface QuestionMediaProps {
@@ -26,16 +26,16 @@ interface QuestionMediaProps {
altText?: string;
}
export const QuestionMedia = ({ imgUrl, videoUrl, altText = "Image" }: QuestionMediaProps) => {
export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionMediaProps) {
const videoUrlWithParams = videoUrl ? getVideoUrlWithParams(videoUrl) : undefined;
const [isLoading, setIsLoading] = useState(true);
return (
<div className="fb-group/image fb-relative fb-mb-4 fb-block fb-min-h-40 fb-rounded-md">
{isLoading && (
{isLoading ? (
<div className="fb-absolute fb-inset-auto fb-flex fb-h-full fb-w-full fb-animate-pulse fb-items-center fb-justify-center fb-rounded-md fb-bg-slate-200" />
)}
{imgUrl && (
) : null}
{imgUrl ? (
<img
key={imgUrl}
src={imgUrl}
@@ -45,23 +45,26 @@ export const QuestionMedia = ({ imgUrl, videoUrl, altText = "Image" }: QuestionM
setIsLoading(false);
}}
/>
)}
{videoUrlWithParams && (
) : null}
{videoUrlWithParams ? (
<div className="fb-relative">
<div className="fb-rounded-custom fb-bg-black">
<iframe
src={videoUrlWithParams}
title="Question Video"
frameborder="0"
frameBorder="0"
className="fb-rounded-custom fb-aspect-video fb-w-full"
onLoad={() => setIsLoading(false)}
onLoad={() => {
setIsLoading(false);
}}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"></iframe>
referrerpolicy="strict-origin-when-cross-origin"
/>
</div>
</div>
)}
) : null}
<a
href={!!imgUrl ? imgUrl : parseVideoUrl(videoUrl ?? "")}
href={imgUrl ? imgUrl : parseVideoUrl(videoUrl ?? "")}
target="_blank"
rel="noreferrer"
className="fb-absolute fb-bottom-2 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-opacity-0 fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100">
@@ -75,7 +78,7 @@ export const QuestionMedia = ({ imgUrl, videoUrl, altText = "Image" }: QuestionM
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
class="lucide lucide-expand">
className="lucide lucide-expand">
<path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8" />
<path d="M3 16.2V21m0 0h4.8M3 21l6-6" />
<path d="M21 7.8V3m0 0h-4.8M21 3l-6 6" />
@@ -84,4 +87,4 @@ export const QuestionMedia = ({ imgUrl, videoUrl, altText = "Image" }: QuestionM
</a>
</div>
);
};
}

View File

@@ -7,13 +7,13 @@ interface RedirectCountDownProps {
isRedirectDisabled: boolean;
}
export const RedirectCountDown = ({ redirectUrl, isRedirectDisabled }: RedirectCountDownProps) => {
export function RedirectCountDown({ redirectUrl, isRedirectDisabled }: RedirectCountDownProps) {
const [timeRemaining, setTimeRemaining] = useState(REDIRECT_TIMEOUT);
useEffect(() => {
let interval: NodeJS.Timeout | undefined;
if (redirectUrl) {
const interval = setInterval(() => {
interval = setInterval(() => {
setTimeRemaining((prevTime) => {
if (prevTime <= 0) {
clearInterval(interval);
@@ -28,7 +28,7 @@ export const RedirectCountDown = ({ redirectUrl, isRedirectDisabled }: RedirectC
}
// Clean up the interval when the component is unmounted
return () => clearInterval(interval);
return () => { clearInterval(interval); };
}, [redirectUrl, isRedirectDisabled]);
if (!redirectUrl) return null;
@@ -41,4 +41,4 @@ export const RedirectCountDown = ({ redirectUrl, isRedirectDisabled }: RedirectC
</div>
</div>
);
};
}

View File

@@ -0,0 +1,51 @@
import { SubmitButton } from "@/components/buttons/submit-button";
import { processResponseData } from "@formbricks/lib/responses";
import { type TResponseData } from "@formbricks/types/responses";
import { type TSurveyQuestion } from "@formbricks/types/surveys/types";
interface ResponseErrorComponentProps {
questions: TSurveyQuestion[];
responseData: TResponseData;
onRetry?: () => void;
}
export function ResponseErrorComponent({ questions, responseData, onRetry }: ResponseErrorComponentProps) {
return (
<div className="fb-flex fb-flex-col fb-bg-white fb-p-4">
<span className="fb-mb-1.5 fb-text-base fb-font-bold fb-leading-6 fb-text-slate-900">
Your feedback is stuck :(
</span>
<p className="fb-max-w-md fb-text-sm fb-font-normal fb-leading-6 fb-text-slate-600">
The servers cannot be reached at the moment.
<br />
Please retry now or try again later.
</p>
<div className="fb-mt-4 fb-rounded-lg fb-border fb-border-slate-200 fb-bg-slate-100 fb-px-4 fb-py-5">
<div className="fb-flex fb-max-h-36 fb-flex-1 fb-flex-col fb-space-y-3 fb-overflow-y-scroll">
{questions.map((question, index) => {
const response = responseData[question.id];
if (!response) return;
return (
<div className="fb-flex fb-flex-col" key={`response-${index.toString()}`}>
<span className="fb-text-sm fb-leading-6 fb-text-slate-900">{`Question ${(index + 1).toString()}`}</span>
<span className="fb-mt-1 fb-text-sm fb-font-semibold fb-leading-6 fb-text-slate-900">
{processResponseData(response)}
</span>
</div>
);
})}
</div>
</div>
<div className="fb-mt-4 fb-flex fb-flex-1 fb-flex-row fb-items-center fb-justify-end fb-space-x-2">
<SubmitButton
tabIndex={2}
buttonLabel="Retry"
isLastQuestion={false}
onClick={() => {
onRetry?.();
}}
/>
</div>
</div>
);
}

View File

@@ -1,5 +1,4 @@
import type { JSX } from "preact";
import { FunctionComponent } from "preact";
import type { FunctionComponent, JSX } from "preact";
export const TiredFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement>> = (props) => {
return (
@@ -468,4 +467,4 @@ export const GrinningSquintingFace: FunctionComponent<JSX.HTMLAttributes<SVGCirc
);
};
export let icons = [<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg"></svg>];
export const icons = [<svg key="smiley" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" />];

View File

@@ -1,11 +1,11 @@
import { TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
interface SubheaderProps {
subheader?: string;
questionId: TSurveyQuestionId;
}
export const Subheader = ({ subheader, questionId }: SubheaderProps) => {
export function Subheader({ subheader, questionId }: SubheaderProps) {
return (
<p
htmlFor={questionId}
@@ -14,4 +14,4 @@ export const Subheader = ({ subheader, questionId }: SubheaderProps) => {
{subheader}
</p>
);
};
}

View File

@@ -1,16 +1,16 @@
interface SurveyCloseButtonProps {
onClose: () => void;
onClose?: () => void;
}
export const SurveyCloseButton = ({ onClose }: SurveyCloseButtonProps) => {
export function SurveyCloseButton({ onClose }: SurveyCloseButtonProps) {
return (
<div class="fb-z-[1001] fb-flex fb-w-fit fb-items-center even:fb-border-l even:fb-pl-1">
<div className="fb-z-[1001] fb-flex fb-w-fit fb-items-center even:fb-border-l even:fb-pl-1">
<button
type="button"
onClick={onClose}
class="fb-text-heading fb-relative fb-h-5 fb-w-5 fb-rounded-md hover:fb-bg-black/5 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2">
className="fb-text-heading fb-relative fb-h-5 fb-w-5 fb-rounded-md hover:fb-bg-black/5 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2">
<svg
class="fb-h-5 fb-w-5 fb-p-0.5"
className="fb-h-5 fb-w-5 fb-p-0.5"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1"
@@ -21,4 +21,4 @@ export const SurveyCloseButton = ({ onClose }: SurveyCloseButtonProps) => {
</button>
</div>
);
};
}

View File

@@ -1,7 +1,7 @@
import { SurveyInlineProps } from "@formbricks/types/formbricks-surveys";
import { Survey } from "./Survey";
import { type SurveyInlineProps } from "@formbricks/types/formbricks-surveys";
import { Survey } from "./survey";
export const SurveyInline = (props: SurveyInlineProps) => {
export function SurveyInline(props: SurveyInlineProps) {
return (
<div
id="fbjs"
@@ -13,4 +13,4 @@ export const SurveyInline = (props: SurveyInlineProps) => {
<Survey {...props} />
</div>
);
};
}

View File

@@ -1,9 +1,9 @@
import { Modal } from "@/components/wrappers/Modal";
import { Modal } from "@/components/wrappers/modal";
import { useState } from "preact/hooks";
import { SurveyModalProps } from "@formbricks/types/formbricks-surveys";
import { Survey } from "./Survey";
import { type SurveyModalProps } from "@formbricks/types/formbricks-surveys";
import { Survey } from "./survey";
export const SurveyModal = ({
export function SurveyModal({
survey,
isBrandingEnabled,
getSetIsError,
@@ -14,7 +14,7 @@ export const SurveyModal = ({
getSetIsResponseSendingFinished,
onResponse,
onClose,
onFinished = () => {},
onFinished,
onFileUpload,
onRetry,
isRedirectDisabled = false,
@@ -22,7 +22,7 @@ export const SurveyModal = ({
responseCount,
styling,
hiddenFieldsRecord,
}: SurveyModalProps) => {
}: SurveyModalProps) {
const [isOpen, setIsOpen] = useState(true);
const close = () => {
@@ -34,7 +34,7 @@ export const SurveyModal = ({
}, 1000); // wait for animation to finish}
};
const highlightBorderColor = styling?.highlightBorderColor?.light || null;
const highlightBorderColor = styling.highlightBorderColor?.light ?? null;
return (
<div id="fbjs" className="fb-formbricks-form">
@@ -54,11 +54,11 @@ export const SurveyModal = ({
languageCode={languageCode}
onClose={close}
onFinished={() => {
onFinished();
onFinished?.();
setTimeout(
() => {
const firstEnabledEnding = survey.endings[0];
if (firstEnabledEnding?.type !== "redirectToUrl") {
if (firstEnabledEnding.type !== "redirectToUrl") {
close();
}
},
@@ -78,4 +78,4 @@ export const SurveyModal = ({
</Modal>
</div>
);
};
}

View File

@@ -1,42 +1,42 @@
import { EndingCard } from "@/components/general/EndingCard";
import { FormbricksBranding } from "@/components/general/FormbricksBranding";
import { LanguageSwitch } from "@/components/general/LanguageSwitch";
import { ProgressBar } from "@/components/general/ProgressBar";
import { QuestionConditional } from "@/components/general/QuestionConditional";
import { ResponseErrorComponent } from "@/components/general/ResponseErrorComponent";
import { SurveyCloseButton } from "@/components/general/SurveyCloseButton";
import { WelcomeCard } from "@/components/general/WelcomeCard";
import { AutoCloseWrapper } from "@/components/wrappers/AutoCloseWrapper";
import { StackedCardsContainer } from "@/components/wrappers/StackedCardsContainer";
import { EndingCard } from "@/components/general/ending-card";
import { FormbricksBranding } from "@/components/general/formbricks-branding";
import { LanguageSwitch } from "@/components/general/language-switch";
import { ProgressBar } from "@/components/general/progress-bar";
import { QuestionConditional } from "@/components/general/question-conditional";
import { ResponseErrorComponent } from "@/components/general/response-error-component";
import { SurveyCloseButton } from "@/components/general/survey-close-button";
import { WelcomeCard } from "@/components/general/welcome-card";
import { AutoCloseWrapper } from "@/components/wrappers/auto-close-wrapper";
import { StackedCardsContainer } from "@/components/wrappers/stacked-cards-container";
import { parseRecallInformation } from "@/lib/recall";
import { cn } from "@/lib/utils";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import type { JSX } from "react";
import { evaluateLogic, performActions } from "@formbricks/lib/surveyLogic/utils";
import { SurveyBaseProps } from "@formbricks/types/formbricks-surveys";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { type SurveyBaseProps } from "@formbricks/types/formbricks-surveys";
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import type {
TResponseData,
TResponseDataValue,
TResponseTtc,
TResponseVariables,
} from "@formbricks/types/responses";
import { TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
interface VariableStackEntry {
questionId: TSurveyQuestionId;
variables: TResponseVariables;
}
export const Survey = ({
export function Survey({
survey,
styling,
isBrandingEnabled,
onDisplay = () => {},
onResponse = () => {},
onClose = () => {},
onFinished = () => {},
onRetry = () => {},
onDisplay,
onResponse,
onClose,
onFinished,
onRetry,
isRedirectDisabled = false,
prefillResponseData,
skipPrefilled,
@@ -53,7 +53,7 @@ export const Survey = ({
shouldResetQuestionId,
fullSizeCards = false,
autoFocus,
}: SurveyBaseProps) => {
}: SurveyBaseProps) {
const [localSurvey, setlocalSurvey] = useState<TJsEnvironmentStateSurvey>(survey);
// Update localSurvey when the survey prop changes (it changes in case of survey editor)
@@ -61,21 +61,20 @@ export const Survey = ({
setlocalSurvey(survey);
}, [survey]);
const autoFocusEnabled = autoFocus !== undefined ? autoFocus : window.self === window.top;
const autoFocusEnabled = autoFocus ?? window.self === window.top;
const [questionId, setQuestionId] = useState(() => {
if (startAtQuestionId) {
return startAtQuestionId;
} else if (localSurvey.welcomeCard.enabled) {
return "start";
} else {
return localSurvey?.questions[0]?.id;
}
return localSurvey.questions[0]?.id;
});
const [showError, setShowError] = useState(false);
// flag state to store whether response processing has been completed or not, we ignore this check for survey editor preview and link survey preview where getSetIsResponseSendingFinished is undefined
const [isResponseSendingFinished, setIsResponseSendingFinished] = useState(
getSetIsResponseSendingFinished ? false : true
!getSetIsResponseSendingFinished
);
const [selectedLanguage, setselectedLanguage] = useState(languageCode);
const [loadingElement, setLoadingElement] = useState(false);
@@ -83,10 +82,10 @@ export const Survey = ({
const [responseData, setResponseData] = useState<TResponseData>(hiddenFieldsRecord ?? {});
const [_variableStack, setVariableStack] = useState<VariableStackEntry[]>([]);
const [currentVariables, setCurrentVariables] = useState<TResponseVariables>(() => {
return localSurvey.variables.reduce((acc, variable) => {
return localSurvey.variables.reduce<TResponseVariables>((acc, variable) => {
acc[variable.id] = variable.value;
return acc;
}, {} as TResponseVariables);
}, {});
});
const [ttc, setTtc] = useState<TResponseTtc>({});
@@ -97,9 +96,8 @@ export const Survey = ({
const cardArrangement = useMemo(() => {
if (localSurvey.type === "link") {
return styling.cardArrangement?.linkSurveys ?? "straight";
} else {
return styling.cardArrangement?.appSurveys ?? "straight";
}
return styling.cardArrangement?.appSurveys ?? "straight";
}, [localSurvey.type, styling.cardArrangement?.linkSurveys, styling.cardArrangement?.appSurveys]);
const currentQuestionIndex = localSurvey.questions.findIndex((q) => q.id === questionId);
@@ -108,15 +106,14 @@ export const Survey = ({
const newHistory = [...history];
const prevQuestionId = newHistory.pop();
return localSurvey.questions.find((q) => q.id === prevQuestionId);
} else {
return localSurvey.questions.find((q) => q.id === questionId);
}
return localSurvey.questions.find((q) => q.id === questionId);
}, [questionId, localSurvey, history]);
const contentRef = useRef<HTMLDivElement | null>(null);
const showProgressBar = !styling.hideProgressBar;
const getShowSurveyCloseButton = (offset: number) => {
return offset === 0 && localSurvey.type !== "link" && (clickOutside === undefined ? true : clickOutside);
return offset === 0 && localSurvey.type !== "link" && (clickOutside ?? true);
};
const getShowLanguageSwitch = (offset: number) => {
return localSurvey.showLanguageSwitch && localSurvey.languages.length > 0 && offset <= 0;
@@ -131,9 +128,9 @@ export const Survey = ({
useEffect(() => {
// call onDisplay when component is mounted
onDisplay();
onDisplay?.();
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps -- onDisplay should only be called once
}, []);
useEffect(() => {
@@ -172,9 +169,6 @@ export const Survey = ({
setselectedLanguage(languageCode);
}, [languageCode]);
let currIdxTemp = currentQuestionIndex;
let currQuesTemp = currentQuestion;
const onChange = (responseDataUpdate: TResponseData) => {
const updatedResponseData = { ...responseData, ...responseDataUpdate };
setResponseData(updatedResponseData);
@@ -185,11 +179,11 @@ export const Survey = ({
setCurrentVariables(updatedVariables);
};
const makeQuestionsRequired = (questionIds: string[]): void => {
const makeQuestionsRequired = (requiredQuestionIds: string[]): void => {
setlocalSurvey((prevSurvey) => ({
...prevSurvey,
questions: prevSurvey.questions.map((question) => {
if (questionIds.includes(question.id)) {
if (requiredQuestionIds.includes(question.id)) {
return {
...question,
required: true,
@@ -200,8 +194,11 @@ export const Survey = ({
}));
};
const pushVariableState = (questionId: TSurveyQuestionId) => {
setVariableStack((prevStack) => [...prevStack, { questionId, variables: { ...currentVariables } }]);
const pushVariableState = (currentQuestionId: TSurveyQuestionId) => {
setVariableStack((prevStack) => [
...prevStack,
{ questionId: currentQuestionId, variables: { ...currentVariables } },
]);
};
const popVariableState = () => {
@@ -224,7 +221,7 @@ export const Survey = ({
if (questionId === "start")
return { nextQuestionId: questions[0]?.id || firstEndingId, calculatedVariables: {} };
if (!currQuesTemp) throw new Error("Question not found");
if (!currentQuestion) throw new Error("Question not found");
let firstJumpTarget: string | undefined;
const allRequiredQuestionIds: string[] = [];
@@ -232,8 +229,8 @@ export const Survey = ({
let calculationResults = { ...currentVariables };
const localResponseData = { ...responseData, ...data };
if (currQuesTemp.logic && currQuesTemp.logic.length > 0) {
for (const logic of currQuesTemp.logic) {
if (currentQuestion.logic && currentQuestion.logic.length > 0) {
for (const logic of currentQuestion.logic) {
if (
evaluateLogic(
localSurvey,
@@ -261,8 +258,8 @@ export const Survey = ({
}
// Use logicFallback if no jump target was set
if (!firstJumpTarget && currQuesTemp.logicFallback) {
firstJumpTarget = currQuesTemp.logicFallback;
if (!firstJumpTarget && currentQuestion.logicFallback) {
firstJumpTarget = currentQuestion.logicFallback;
}
// Make all collected questions required
@@ -271,18 +268,18 @@ export const Survey = ({
}
// Return the first jump target if found, otherwise go to the next question or ending
const nextQuestionId = firstJumpTarget || questions[currentQuestionIndex + 1]?.id || firstEndingId;
const nextQuestionId = firstJumpTarget ?? questions[currentQuestionIndex + 1]?.id ?? firstEndingId;
return { nextQuestionId, calculatedVariables: calculationResults };
};
const onSubmit = (responseData: TResponseData, ttc: TResponseTtc) => {
const questionId = Object.keys(responseData)[0];
const onSubmit = (surveyResponseData: TResponseData, responsettc: TResponseTtc) => {
const respondedQuestionId = Object.keys(surveyResponseData)[0];
setLoadingElement(true);
pushVariableState(questionId);
pushVariableState(respondedQuestionId);
const { nextQuestionId, calculatedVariables } = evaluateLogicAndGetNextQuestionId(responseData);
const { nextQuestionId, calculatedVariables } = evaluateLogicAndGetNextQuestionId(surveyResponseData);
const finished =
nextQuestionId === undefined ||
!localSurvey.questions.map((question) => question.id).includes(nextQuestionId);
@@ -291,11 +288,11 @@ export const Survey = ({
? localSurvey.endings.find((ending) => ending.id === nextQuestionId)?.id
: undefined;
onChange(responseData);
onChange(surveyResponseData);
onChangeVariables(calculatedVariables);
onResponse({
data: responseData,
ttc,
onResponse?.({
data: surveyResponseData,
ttc: responsettc,
finished,
variables: calculatedVariables,
language: selectedLanguage,
@@ -304,26 +301,26 @@ export const Survey = ({
if (finished) {
// Post a message to the parent window indicating that the survey is completed.
window.parent.postMessage("formbricksSurveyCompleted", "*");
onFinished();
onFinished?.();
}
if (nextQuestionId) {
setQuestionId(nextQuestionId);
}
// add to history
setHistory([...history, questionId]);
setHistory([...history, respondedQuestionId]);
setLoadingElement(false);
};
const onBack = (): void => {
let prevQuestionId;
// use history if available
if (history?.length > 0) {
if (history.length > 0) {
const newHistory = [...history];
prevQuestionId = newHistory.pop();
setHistory(newHistory);
} else {
// otherwise go back to previous question in array
prevQuestionId = localSurvey.questions[currIdxTemp - 1]?.id;
prevQuestionId = localSurvey.questions[currentQuestionIndex - 1]?.id;
}
popVariableState();
if (!prevQuestionId) throw new Error("Question not found");
@@ -331,11 +328,11 @@ export const Survey = ({
};
const getQuestionPrefillData = (
questionId: TSurveyQuestionId,
prefillQuestionId: TSurveyQuestionId,
offset: number
): TResponseDataValue | undefined => {
if (offset === 0 && prefillResponseData) {
return prefillResponseData[questionId];
return prefillResponseData[prefillQuestionId];
}
return undefined;
};
@@ -392,7 +389,7 @@ export const Survey = ({
} else {
const question = localSurvey.questions[questionIdx];
return (
question && (
Boolean(question) && (
<QuestionConditional
key={question.id}
surveyId={localSurvey.id}
@@ -404,7 +401,7 @@ export const Survey = ({
ttc={ttc}
setTtc={setTtc}
onFileUpload={onFileUpload}
isFirstQuestion={question.id === localSurvey?.questions[0]?.id}
isFirstQuestion={question.id === localSurvey.questions[0]?.id}
skipPrefilled={skipPrefilled}
prefilledQuestionValue={getQuestionPrefillData(question.id, offset)}
isLastQuestion={question.id === localSurvey.questions[localSurvey.questions.length - 1].id}
@@ -443,8 +440,8 @@ export const Survey = ({
{content()}
</div>
<div className="fb-mx-6 fb-mb-10 fb-mt-2 fb-space-y-3 sm:fb-mb-6 sm:fb-mt-6">
{isBrandingEnabled && <FormbricksBranding />}
{showProgressBar && <ProgressBar survey={localSurvey} questionId={questionId} />}
{isBrandingEnabled ? <FormbricksBranding /> : null}
{showProgressBar ? <ProgressBar survey={localSurvey} questionId={questionId} /> : null}
</div>
</div>
</AutoCloseWrapper>
@@ -452,17 +449,15 @@ export const Survey = ({
};
return (
<>
<StackedCardsContainer
cardArrangement={cardArrangement}
currentQuestionId={questionId}
getCardContent={getCardContent}
survey={localSurvey}
styling={styling}
setQuestionId={setQuestionId}
shouldResetQuestionId={shouldResetQuestionId}
fullSizeCards={fullSizeCards}
/>
</>
<StackedCardsContainer
cardArrangement={cardArrangement}
currentQuestionId={questionId}
getCardContent={getCardContent}
survey={localSurvey}
styling={styling}
setQuestionId={setQuestionId}
shouldResetQuestionId={shouldResetQuestionId}
fullSizeCards={fullSizeCards}
/>
);
};
}

View File

@@ -1,14 +1,14 @@
import { SubmitButton } from "@/components/buttons/SubmitButton";
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
import { SubmitButton } from "@/components/buttons/submit-button";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { replaceRecallInfo } from "@/lib/recall";
import { calculateElementIdx } from "@/lib/utils";
import { useEffect } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TResponseData, TResponseTtc, TResponseVariables } from "@formbricks/types/responses";
import { TI18nString } from "@formbricks/types/surveys/types";
import { Headline } from "./Headline";
import { HtmlBody } from "./HtmlBody";
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { type TResponseData, type TResponseTtc, type TResponseVariables } from "@formbricks/types/responses";
import { type TI18nString } from "@formbricks/types/surveys/types";
import { Headline } from "./headline";
import { HtmlBody } from "./html-body";
interface WelcomeCardProps {
headline?: TI18nString;
@@ -25,7 +25,7 @@ interface WelcomeCardProps {
variablesData: TResponseVariables;
}
const TimerIcon = () => {
function TimerIcon() {
return (
<div className="fb-mr-1">
<svg
@@ -33,16 +33,16 @@ const TimerIcon = () => {
width="16"
height="16"
fill="currentColor"
class="bi bi-stopwatch"
className="bi bi-stopwatch"
viewBox="0 0 16 16">
<path d="M8.5 5.6a.5.5 0 1 0-1 0v2.9h-3a.5.5 0 0 0 0 1H8a.5.5 0 0 0 .5-.5V5.6z" />
<path d="M6.5 1A.5.5 0 0 1 7 .5h2a.5.5 0 0 1 0 1v.57c1.36.196 2.594.78 3.584 1.64a.715.715 0 0 1 .012-.013l.354-.354-.354-.353a.5.5 0 0 1 .707-.708l1.414 1.415a.5.5 0 1 1-.707.707l-.353-.354-.354.354a.512.512 0 0 1-.013.012A7 7 0 1 1 7 2.071V1.5a.5.5 0 0 1-.5-.5zM8 3a6 6 0 1 0 .001 12A6 6 0 0 0 8 3z" />
</svg>
</div>
);
};
}
const UsersIcon = () => {
function UsersIcon() {
return (
<div className="fb-mr-1">
<svg
@@ -51,7 +51,7 @@ const UsersIcon = () => {
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
class="fb-h-4 fb-w-4">
className="fb-h-4 fb-w-4">
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -60,9 +60,9 @@ const UsersIcon = () => {
</svg>
</div>
);
};
}
export const WelcomeCard = ({
export function WelcomeCard({
headline,
html,
fileUrl,
@@ -75,7 +75,7 @@ export const WelcomeCard = ({
isCurrent,
responseData,
variablesData,
}: WelcomeCardProps) => {
}: WelcomeCardProps) {
const calculateTimeToComplete = () => {
let idx = calculateElementIdx(survey, 0);
if (idx === 0.5) {
@@ -95,20 +95,19 @@ export const WelcomeCard = ({
if (minutes === 0) {
// If less than 1 minute, return 'less than 1 minute'
return "less than 1 minute";
} else {
// If more than 1 minute, return 'less than X minutes', where X is minutes + 1
return `less than ${minutes + 1} minutes`;
}
// If more than 1 minute, return 'less than X minutes', where X is minutes + 1
return `less than ${(minutes + 1).toString()} minutes`;
}
// If there are no remaining seconds, just return the number of minutes
return `${minutes} minutes`;
return `${minutes.toString()} minutes`;
};
const timeToFinish = survey.welcomeCard.timeToFinish;
const showResponseCount = survey.welcomeCard.showResponseCount;
const handleSubmit = () => {
onSubmit({ ["welcomeCard"]: "clicked" }, {});
onSubmit({ welcomeCard: "clicked" }, {});
};
useEffect(() => {
@@ -128,20 +127,20 @@ export const WelcomeCard = ({
document.removeEventListener("keydown", handleEnter);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps -- only want to run this effect when isCurrent changes
}, [isCurrent]);
return (
<div>
<ScrollableContainer>
<div>
{fileUrl && (
{fileUrl ? (
<img
src={fileUrl}
className="fb-mb-8 fb-max-h-96 fb-w-1/3 fb-rounded-lg fb-object-contain"
alt="Company Logo"
/>
)}
) : null}
<Headline
headline={replaceRecallInfo(
@@ -157,7 +156,6 @@ export const WelcomeCard = ({
/>
</div>
</ScrollableContainer>
<div className="fb-mx-6 fb-mt-4 fb-flex fb-gap-4 fb-py-4">
<SubmitButton
buttonLabel={getLocalizedValue(buttonLabel, languageCode)}
@@ -166,10 +164,13 @@ export const WelcomeCard = ({
tabIndex={isCurrent ? 0 : -1}
onClick={handleSubmit}
type="button"
onKeyDown={(e) => e.key === "Enter" && e.preventDefault()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
}
}}
/>
</div>
{timeToFinish && !showResponseCount ? (
<div className="fb-items-center fb-text-subheading fb-my-4 fb-ml-6 fb-flex">
<TimerIcon />
@@ -177,22 +178,26 @@ export const WelcomeCard = ({
<span> Takes {calculateTimeToComplete()} </span>
</p>
</div>
) : showResponseCount && !timeToFinish && responseCount && responseCount > 3 ? (
) : null}
{showResponseCount && !timeToFinish && responseCount && responseCount > 3 ? (
<div className="fb-items-center fb-text-subheading fb-my-4 fb-ml-6 fb-flex">
<UsersIcon />
<p className="fb-pt-1 fb-text-xs">
<span>{`${responseCount} people responded`}</span>
<span>{`${responseCount.toString()} people responded`}</span>
</p>
</div>
) : timeToFinish && showResponseCount ? (
) : null}
{timeToFinish && showResponseCount ? (
<div className="fb-items-center fb-text-subheading fb-my-4 fb-ml-6 fb-flex">
<TimerIcon />
<p className="fb-pt-1 fb-text-xs">
<span> Takes {calculateTimeToComplete()} </span>
<span>{responseCount && responseCount > 3 ? `${responseCount} people responded` : ""}</span>
<span>
{responseCount && responseCount > 3 ? `${responseCount.toString()} people responded` : ""}
</span>
</p>
</div>
) : null}
</div>
);
};
}

View File

@@ -1,15 +1,15 @@
import { BackButton } from "@/components/buttons/BackButton";
import { SubmitButton } from "@/components/buttons/SubmitButton";
import { Headline } from "@/components/general/Headline";
import { Input } from "@/components/general/Input";
import { QuestionMedia } from "@/components/general/QuestionMedia";
import { Subheader } from "@/components/general/Subheader";
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
import { Input } from "@/components/general/input";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { useMemo, useRef, useState } from "preact/hooks";
import { useCallback } from "react";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyAddressQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
interface AddressQuestionProps {
@@ -27,7 +27,7 @@ interface AddressQuestionProps {
autoFocusEnabled: boolean;
}
export const AddressQuestion = ({
export function AddressQuestion({
question,
value,
onChange,
@@ -40,7 +40,7 @@ export const AddressQuestion = ({
setTtc,
currentQuestionId,
autoFocusEnabled,
}: AddressQuestionProps) => {
}: AddressQuestionProps) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const formRef = useRef<HTMLFormElement>(null);
@@ -88,7 +88,7 @@ export const AddressQuestion = ({
if (field.id === fieldId) {
return fieldValue;
}
const existingValue = safeValue?.[fields.findIndex((f) => f.id === field.id)] || "";
const existingValue = safeValue[fields.findIndex((f) => f.id === field.id)] || "";
return field.show ? existingValue : "";
});
onChange({ [question.id]: newValue });
@@ -98,11 +98,11 @@ export const AddressQuestion = ({
e.preventDefault();
const updatedTtc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtc);
const containsAllEmptyStrings = safeValue?.length === 6 && safeValue.every((item) => item.trim() === "");
const containsAllEmptyStrings = safeValue.length === 6 && safeValue.every((item) => item.trim() === "");
if (containsAllEmptyStrings) {
onSubmit({ [question.id]: [] }, updatedTtc);
} else {
onSubmit({ [question.id]: safeValue ?? [] }, updatedTtc);
onSubmit({ [question.id]: safeValue }, updatedTtc);
}
};
@@ -120,7 +120,9 @@ export const AddressQuestion = ({
<form key={question.id} onSubmit={handleSubmit} className="fb-w-full" ref={formRef}>
<ScrollableContainer>
<div>
{isMediaAvailable && <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />}
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
@@ -131,7 +133,7 @@ export const AddressQuestion = ({
questionId={question.id}
/>
<div className={`fb-flex fb-flex-col fb-space-y-2 fb-mt-4 fb-w-full`}>
<div className="fb-flex fb-flex-col fb-space-y-2 fb-mt-4 fb-w-full">
{fields.map((field, index) => {
const isFieldRequired = () => {
if (field.required) {
@@ -140,7 +142,7 @@ export const AddressQuestion = ({
// if all fields are optional and the question is required, then the fields should be required
if (
fields.filter((field) => field.show).every((field) => !field.required) &&
fields.filter((currField) => currField.show).every((currField) => !currField.required) &&
question.required
) {
return true;
@@ -155,10 +157,12 @@ export const AddressQuestion = ({
key={field.id}
placeholder={isFieldRequired() ? `${field.placeholder}*` : field.placeholder}
required={isFieldRequired()}
value={safeValue?.[index] || ""}
value={safeValue[index] || ""}
className="fb-py-3"
type={field.id === "email" ? "email" : "text"}
onChange={(e) => handleChange(field.id, e?.currentTarget?.value ?? "")}
onChange={(e) => {
handleChange(field.id, e.currentTarget.value);
}}
ref={index === 0 ? addressRef : null}
tabIndex={isCurrent ? 0 : -1}
/>
@@ -173,9 +177,8 @@ export const AddressQuestion = ({
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
onClick={() => {}}
/>
<div></div>
<div />
{!isFirstQuestion && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
@@ -190,4 +193,4 @@ export const AddressQuestion = ({
</div>
</form>
);
};
}

View File

@@ -1,16 +1,15 @@
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 { QuestionMedia } from "@/components/general/QuestionMedia";
import { Subheader } from "@/components/general/Subheader";
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { CalEmbed } from "@/components/general/cal-embed";
import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { useCallback, useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TResponseData } from "@formbricks/types/responses";
import { TResponseTtc } from "@formbricks/types/responses";
import { TSurveyCalQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import { type TSurveyCalQuestion, type TSurveyQuestionId } from "@formbricks/types/surveys/types";
interface CalQuestionProps {
question: TSurveyCalQuestion;
@@ -27,7 +26,7 @@ interface CalQuestionProps {
currentQuestionId: TSurveyQuestionId;
}
export const CalQuestion = ({
export function CalQuestion({
question,
value,
onChange,
@@ -39,7 +38,7 @@ export const CalQuestion = ({
ttc,
setTtc,
currentQuestionId,
}: CalQuestionProps) => {
}: CalQuestionProps) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const [errorMessage, setErrorMessage] = useState("");
@@ -71,7 +70,9 @@ export const CalQuestion = ({
className="fb-w-full">
<ScrollableContainer>
<div>
{isMediaAvailable && <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />}
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
@@ -81,10 +82,8 @@ export const CalQuestion = ({
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<>
{errorMessage && <span className="fb-text-red-500">{errorMessage}</span>}
<CalEmbed key={question.id} question={question} onSuccessfulBooking={onSuccessfulBooking} />
</>
{errorMessage ? <span className="fb-text-red-500">{errorMessage}</span> : null}
<CalEmbed key={question.id} question={question} onSuccessfulBooking={onSuccessfulBooking} />
</div>
</ScrollableContainer>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-px-6 fb-py-4">
@@ -95,7 +94,7 @@ export const CalQuestion = ({
tabIndex={isCurrent ? 0 : -1}
/>
)}
<div></div>
<div />
{!isFirstQuestion && (
<BackButton
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
@@ -108,4 +107,4 @@ export const CalQuestion = ({
</div>
</form>
);
};
}

View File

@@ -1,13 +1,13 @@
import { BackButton } from "@/components/buttons/BackButton";
import { SubmitButton } from "@/components/buttons/SubmitButton";
import { Headline } from "@/components/general/Headline";
import { HtmlBody } from "@/components/general/HtmlBody";
import { QuestionMedia } from "@/components/general/QuestionMedia";
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
import { HtmlBody } from "@/components/general/html-body";
import { QuestionMedia } from "@/components/general/question-media";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { useCallback, useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyConsentQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
interface ConsentQuestionProps {
@@ -25,7 +25,7 @@ interface ConsentQuestionProps {
currentQuestionId: TSurveyQuestionId;
}
export const ConsentQuestion = ({
export function ConsentQuestion({
question,
value,
onChange,
@@ -38,7 +38,7 @@ export const ConsentQuestion = ({
setTtc,
currentQuestionId,
autoFocusEnabled,
}: ConsentQuestionProps) => {
}: ConsentQuestionProps) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const isCurrent = question.id === currentQuestionId;
@@ -66,7 +66,9 @@ export const ConsentQuestion = ({
}}>
<ScrollableContainer>
<div>
{isMediaAvailable && <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />}
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
@@ -123,7 +125,7 @@ export const ConsentQuestion = ({
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
/>
<div></div>
<div />
{!isFirstQuestion && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
@@ -139,4 +141,4 @@ export const ConsentQuestion = ({
</div>
</form>
);
};
}

View File

@@ -1,14 +1,14 @@
import { BackButton } from "@/components/buttons/BackButton";
import { SubmitButton } from "@/components/buttons/SubmitButton";
import { Headline } from "@/components/general/Headline";
import { Input } from "@/components/general/Input";
import { QuestionMedia } from "@/components/general/QuestionMedia";
import { Subheader } from "@/components/general/Subheader";
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
import { Input } from "@/components/general/input";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { useCallback, useMemo, useRef, useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyContactInfoQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
interface ContactInfoQuestionProps {
@@ -27,7 +27,7 @@ interface ContactInfoQuestionProps {
autoFocusEnabled: boolean;
}
export const ContactInfoQuestion = ({
export function ContactInfoQuestion({
question,
value,
onChange,
@@ -40,7 +40,7 @@ export const ContactInfoQuestion = ({
setTtc,
currentQuestionId,
autoFocusEnabled,
}: ContactInfoQuestionProps) => {
}: ContactInfoQuestionProps) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const formRef = useRef<HTMLFormElement>(null);
@@ -83,7 +83,7 @@ export const ContactInfoQuestion = ({
if (field.id === fieldId) {
return fieldValue;
}
const existingValue = safeValue?.[fields.findIndex((f) => f.id === field.id)] || "";
const existingValue = safeValue[fields.findIndex((f) => f.id === field.id)] || "";
return field.show ? existingValue : "";
});
onChange({ [question.id]: newValue });
@@ -93,11 +93,11 @@ export const ContactInfoQuestion = ({
e.preventDefault();
const updatedTtc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtc);
const containsAllEmptyStrings = safeValue?.length === 5 && safeValue.every((item) => item.trim() === "");
const containsAllEmptyStrings = safeValue.length === 5 && safeValue.every((item) => item.trim() === "");
if (containsAllEmptyStrings) {
onSubmit({ [question.id]: [] }, updatedTtc);
} else {
onSubmit({ [question.id]: safeValue ?? [] }, updatedTtc);
onSubmit({ [question.id]: safeValue }, updatedTtc);
}
};
@@ -115,7 +115,9 @@ export const ContactInfoQuestion = ({
<form key={question.id} onSubmit={handleSubmit} className="fb-w-full" ref={formRef}>
<ScrollableContainer>
<div>
{isMediaAvailable && <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />}
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
@@ -126,7 +128,7 @@ export const ContactInfoQuestion = ({
questionId={question.id}
/>
<div className={`fb-flex fb-flex-col fb-space-y-2 fb-mt-4 fb-w-full`}>
<div className="fb-flex fb-flex-col fb-space-y-2 fb-mt-4 fb-w-full">
{fields.map((field, index) => {
const isFieldRequired = () => {
if (field.required) {
@@ -135,7 +137,7 @@ export const ContactInfoQuestion = ({
// if all fields are optional and the question is required, then the fields should be required
if (
fields.filter((field) => field.show).every((field) => !field.required) &&
fields.filter((currField) => currField.show).every((currField) => !currField.required) &&
question.required
) {
return true;
@@ -158,10 +160,12 @@ export const ContactInfoQuestion = ({
key={field.id}
placeholder={isFieldRequired() ? `${field.placeholder}*` : field.placeholder}
required={isFieldRequired()}
value={safeValue?.[index] || ""}
value={safeValue[index] || ""}
className="fb-py-3"
type={inputType}
onChange={(e) => handleChange(field.id, e?.currentTarget?.value ?? "")}
onChange={(e) => {
handleChange(field.id, e.currentTarget.value);
}}
tabIndex={isCurrent ? 0 : -1}
/>
)
@@ -176,7 +180,6 @@ export const ContactInfoQuestion = ({
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
onClick={() => {}}
/>
{!isFirstQuestion && (
<BackButton
@@ -192,4 +195,4 @@ export const ContactInfoQuestion = ({
</div>
</form>
);
};
}

View File

@@ -1,14 +1,13 @@
import { BackButton } from "@/components/buttons/BackButton";
import { SubmitButton } from "@/components/buttons/SubmitButton";
import { Headline } from "@/components/general/Headline";
import { HtmlBody } from "@/components/general/HtmlBody";
import { QuestionMedia } from "@/components/general/QuestionMedia";
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
import { HtmlBody } from "@/components/general/html-body";
import { QuestionMedia } from "@/components/general/question-media";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { useState } from "react";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TResponseData } from "@formbricks/types/responses";
import { TResponseTtc } from "@formbricks/types/responses";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyCTAQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
interface CTAQuestionProps {
@@ -26,7 +25,7 @@ interface CTAQuestionProps {
currentQuestionId: TSurveyQuestionId;
}
export const CTAQuestion = ({
export function CTAQuestion({
question,
onSubmit,
onChange,
@@ -38,7 +37,7 @@ export const CTAQuestion = ({
setTtc,
autoFocusEnabled,
currentQuestionId,
}: CTAQuestionProps) => {
}: CTAQuestionProps) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const isCurrent = question.id === currentQuestionId;
@@ -48,7 +47,9 @@ export const CTAQuestion = ({
<div key={question.id}>
<ScrollableContainer>
<div>
{isMediaAvailable && <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />}
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
@@ -66,7 +67,7 @@ export const CTAQuestion = ({
tabIndex={isCurrent ? 0 : -1}
onClick={() => {
if (question.buttonExternal && question.buttonUrl) {
window?.open(question.buttonUrl, "_blank")?.focus();
window.open(question.buttonUrl, "_blank")?.focus();
}
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
@@ -106,4 +107,4 @@ export const CTAQuestion = ({
</div>
</div>
);
};
}

View File

@@ -1,16 +1,16 @@
import { BackButton } from "@/components/buttons/BackButton";
import { SubmitButton } from "@/components/buttons/SubmitButton";
import { Headline } from "@/components/general/Headline";
import { QuestionMedia } from "@/components/general/QuestionMedia";
import { Subheader } from "@/components/general/Subheader";
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn } from "@/lib/utils";
import { useEffect, useMemo, useState } from "preact/hooks";
import DatePicker from "react-date-picker";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { getMonthName, getOrdinalDate } from "@formbricks/lib/utils/datetime";
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyDateQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import "../../styles/date-picker.css";
@@ -30,52 +30,56 @@ interface DateQuestionProps {
currentQuestionId: TSurveyQuestionId;
}
const CalendarIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
class="lucide lucide-calendar-days">
<path d="M8 2v4" />
<path d="M16 2v4" />
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M3 10h18" />
<path d="M8 14h.01" />
<path d="M12 14h.01" />
<path d="M16 14h.01" />
<path d="M8 18h.01" />
<path d="M12 18h.01" />
<path d="M16 18h.01" />
</svg>
);
function CalendarIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-calendar-days">
<path d="M8 2v4" />
<path d="M16 2v4" />
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M3 10h18" />
<path d="M8 14h.01" />
<path d="M12 14h.01" />
<path d="M16 14h.01" />
<path d="M8 18h.01" />
<path d="M12 18h.01" />
<path d="M16 18h.01" />
</svg>
);
}
const CalendarCheckIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
class="lucide lucide-calendar-check">
<path d="M8 2v4" />
<path d="M16 2v4" />
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M3 10h18" />
<path d="m9 16 2 2 4-4" />
</svg>
);
function CalendarCheckIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-calendar-check">
<path d="M8 2v4" />
<path d="M16 2v4" />
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M3 10h18" />
<path d="m9 16 2 2 4-4" />
</svg>
);
}
export const DateQuestion = ({
export function DateQuestion({
question,
value,
onSubmit,
@@ -87,7 +91,7 @@ export const DateQuestion = ({
setTtc,
ttc,
currentQuestionId,
}: DateQuestionProps) => {
}: DateQuestionProps) {
const [startTime, setStartTime] = useState(performance.now());
const [errorMessage, setErrorMessage] = useState("");
const isMediaAvailable = question.imageUrl || question.videoUrl;
@@ -100,7 +104,7 @@ export const DateQuestion = ({
useEffect(() => {
if (datePickerOpen) {
if (!selectedDate) setSelectedDate(new Date());
const input = document.querySelector(".react-date-picker__inputGroup__input") as HTMLInputElement;
const input: HTMLInputElement = document.querySelector(".react-date-picker__inputGroup__input")!;
if (input) {
input.focus();
}
@@ -108,7 +112,7 @@ export const DateQuestion = ({
}, [datePickerOpen, selectedDate]);
useEffect(() => {
if (!!selectedDate) {
if (selectedDate) {
if (hideInvalid) {
setHideInvalid(false);
}
@@ -142,7 +146,9 @@ export const DateQuestion = ({
className="fb-w-full">
<ScrollableContainer>
<div>
{isMediaAvailable && <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />}
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
@@ -152,7 +158,7 @@ export const DateQuestion = ({
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div className={"fb-text-red-600"}>
<div className="fb-text-red-600">
<span>{errorMessage}</span>
</div>
<div
@@ -161,7 +167,9 @@ export const DateQuestion = ({
<div className="fb-relative">
{!datePickerOpen && (
<div
onClick={() => setDatePickerOpen(true)}
onClick={() => {
setDatePickerOpen(true);
}}
tabIndex={isCurrent ? 0 : -1}
onKeyDown={(e) => {
if (e.key === " ") setDatePickerOpen(true);
@@ -234,9 +242,10 @@ export const DateQuestion = ({
}
// active date class
if (
selectedDate &&
date.getDate() === selectedDate?.getDate() &&
date.getMonth() === selectedDate?.getMonth() &&
date.getFullYear() === selectedDate?.getFullYear()
date.getMonth() === selectedDate.getMonth() &&
date.getFullYear() === selectedDate.getFullYear()
) {
return `${baseClass} !fb-bg-brand !fb-border-border-highlight !fb-text-heading`;
}
@@ -273,4 +282,4 @@ export const DateQuestion = ({
</div>
</form>
);
};
}

View File

@@ -1,17 +1,17 @@
import { SubmitButton } from "@/components/buttons/SubmitButton";
import { Headline } from "@/components/general/Headline";
import { QuestionMedia } from "@/components/general/QuestionMedia";
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TJsFileUploadParams } from "@formbricks/types/js";
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
import { TUploadFileConfig } from "@formbricks/types/storage";
import { type TJsFileUploadParams } from "@formbricks/types/js";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import { type TUploadFileConfig } from "@formbricks/types/storage";
import type { TSurveyFileUploadQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { BackButton } from "../buttons/BackButton";
import { FileInput } from "../general/FileInput";
import { Subheader } from "../general/Subheader";
import { BackButton } from "../buttons/back-button";
import { FileInput } from "../general/file-input";
import { Subheader } from "../general/subheader";
interface FileUploadQuestionProps {
question: TSurveyFileUploadQuestion;
@@ -30,7 +30,7 @@ interface FileUploadQuestionProps {
currentQuestionId: TSurveyQuestionId;
}
export const FileUploadQuestion = ({
export function FileUploadQuestion({
question,
value,
onChange,
@@ -44,7 +44,7 @@ export const FileUploadQuestion = ({
ttc,
setTtc,
currentQuestionId,
}: FileUploadQuestionProps) => {
}: FileUploadQuestionProps) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
@@ -63,18 +63,18 @@ export const FileUploadQuestion = ({
} else {
alert("Please upload a file");
}
} else if (value) {
onSubmit({ [question.id]: value }, updatedTtcObj);
} else {
if (value) {
onSubmit({ [question.id]: value }, updatedTtcObj);
} else {
onSubmit({ [question.id]: "skipped" }, updatedTtcObj);
}
onSubmit({ [question.id]: "skipped" }, updatedTtcObj);
}
}}
className="fb-w-full">
<ScrollableContainer>
<div>
{isMediaAvailable && <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />}
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
@@ -95,12 +95,12 @@ export const FileUploadQuestion = ({
onChange({ [question.id]: "skipped" });
}
}}
fileUrls={value as string[]}
fileUrls={value}
allowMultipleFiles={question.allowMultipleFiles}
{...(!!question.allowedFileExtensions
{...(question.allowedFileExtensions
? { allowedFileExtensions: question.allowedFileExtensions }
: {})}
{...(!!question.maxSizeInMB ? { maxSizeInMB: question.maxSizeInMB } : {})}
{...(question.maxSizeInMB ? { maxSizeInMB: question.maxSizeInMB } : {})}
/>
</div>
</ScrollableContainer>
@@ -122,4 +122,4 @@ export const FileUploadQuestion = ({
</div>
</form>
);
};
}

View File

@@ -1,15 +1,15 @@
import { BackButton } from "@/components/buttons/BackButton";
import { SubmitButton } from "@/components/buttons/SubmitButton";
import { Headline } from "@/components/general/Headline";
import { QuestionMedia } from "@/components/general/QuestionMedia";
import { Subheader } from "@/components/general/Subheader";
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { getShuffledRowIndices } from "@/lib/utils";
import { JSX } from "preact";
import { type JSX } from "preact";
import { useCallback, useMemo, useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TI18nString, TSurveyMatrixQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
interface MatrixQuestionProps {
@@ -26,7 +26,7 @@ interface MatrixQuestionProps {
currentQuestionId: TSurveyQuestionId;
}
export const MatrixQuestion = ({
export function MatrixQuestion({
question,
value,
onChange,
@@ -38,25 +38,25 @@ export const MatrixQuestion = ({
ttc,
setTtc,
currentQuestionId,
}: MatrixQuestionProps) => {
}: MatrixQuestionProps) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
const isCurrent = question.id === currentQuestionId;
const rowShuffleIdx = useMemo(() => {
if (question.shuffleOption) {
if (question.shuffleOption !== "none") {
return getShuffledRowIndices(question.rows.length, question.shuffleOption);
} else {
return question.rows.map((_, id) => id);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
return question.rows.map((_, id) => id);
// eslint-disable-next-line react-hooks/exhaustive-deps -- We only want to recompute when the shuffleOption changes
}, [question.shuffleOption, question.rows.length]);
const questionRows = useMemo(() => {
if (!question.rows) {
if (!question.rows.length) {
return [];
}
if (question.shuffleOption === "none" || question.shuffleOption === undefined) {
if (question.shuffleOption === "none") {
return question.rows;
}
return rowShuffleIdx.map((shuffledIdx) => {
@@ -87,7 +87,7 @@ export const MatrixQuestion = ({
);
const handleSubmit = useCallback(
(e: JSX.TargetedEvent<HTMLFormElement, Event>) => {
(e: JSX.TargetedEvent<HTMLFormElement>) => {
e.preventDefault();
const updatedTtc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtc);
@@ -119,7 +119,9 @@ export const MatrixQuestion = ({
<form key={question.id} onSubmit={handleSubmit} className="fb-w-full">
<ScrollableContainer>
<div>
{isMediaAvailable && <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />}
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
@@ -133,14 +135,14 @@ export const MatrixQuestion = ({
<table className="fb-no-scrollbar fb-min-w-full fb-table-auto fb-border-collapse fb-text-sm">
<thead>
<tr>
<th className="fb-px-4 fb-py-2"></th>
<th className="fb-px-4 fb-py-2" />
{columnsHeaders}
</tr>
</thead>
<tbody>
{questionRows.map((row, rowIndex) => (
// Table rows
<tr className={`${rowIndex % 2 === 0 ? "bg-input-bg" : ""}`}>
<tr className={rowIndex % 2 === 0 ? "bg-input-bg" : ""} key={`row-${rowIndex.toString()}`}>
<td
className="fb-text-heading fb-rounded-l-custom fb-max-w-40 fb-break-words fb-pr-4 fb-pl-2 fb-py-2"
dir="auto">
@@ -148,15 +150,15 @@ export const MatrixQuestion = ({
</td>
{question.columns.map((column, columnIndex) => (
<td
key={columnIndex}
key={`column-${columnIndex.toString()}`}
tabIndex={isCurrent ? 0 : -1}
className={`fb-outline-brand fb-px-4 fb-py-2 fb-text-slate-800 ${columnIndex === question.columns.length - 1 ? "fb-rounded-r-custom" : ""}`}
onClick={() =>
onClick={() => {
handleSelect(
getLocalizedValue(column, languageCode),
getLocalizedValue(row, languageCode)
)
}
);
}}
onKeyDown={(e) => {
if (e.key === " ") {
e.preventDefault();
@@ -174,7 +176,7 @@ export const MatrixQuestion = ({
type="radio"
tabIndex={-1}
required={question.required}
id={`${row}-${column}`}
id={`row${rowIndex.toString()}-column${columnIndex.toString()}`}
name={getLocalizedValue(row, languageCode)}
value={getLocalizedValue(column, languageCode)}
checked={
@@ -199,7 +201,6 @@ export const MatrixQuestion = ({
<SubmitButton
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
onClick={() => {}}
tabIndex={isCurrent ? 0 : -1}
/>
{!isFirstQuestion && (
@@ -212,4 +213,4 @@ export const MatrixQuestion = ({
</div>
</form>
);
};
}

View File

@@ -1,14 +1,14 @@
import { BackButton } from "@/components/buttons/BackButton";
import { SubmitButton } from "@/components/buttons/SubmitButton";
import { Headline } from "@/components/general/Headline";
import { QuestionMedia } from "@/components/general/QuestionMedia";
import { Subheader } from "@/components/general/Subheader";
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn, getShuffledChoicesIds } from "@/lib/utils";
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyMultipleChoiceQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
interface MultipleChoiceMultiProps {
@@ -26,7 +26,7 @@ interface MultipleChoiceMultiProps {
currentQuestionId: TSurveyQuestionId;
}
export const MultipleChoiceMultiQuestion = ({
export function MultipleChoiceMultiQuestion({
question,
value,
onChange,
@@ -39,7 +39,7 @@ export const MultipleChoiceMultiQuestion = ({
setTtc,
autoFocusEnabled,
currentQuestionId,
}: MultipleChoiceMultiProps) => {
}: MultipleChoiceMultiProps) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
@@ -47,8 +47,9 @@ export const MultipleChoiceMultiQuestion = ({
const shuffledChoicesIds = useMemo(() => {
if (question.shuffleOption) {
return getShuffledChoicesIds(question.choices, question.shuffleOption);
} else return question.choices.map((choice) => choice.id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}
return question.choices.map((choice) => choice.id);
// eslint-disable-next-line react-hooks/exhaustive-deps -- We only want to recompute this when the shuffleOption changes
}, [question.shuffleOption, question.choices.length, question.choices[question.choices.length - 1].id]);
const getChoicesWithoutOtherLabels = useCallback(
@@ -63,9 +64,9 @@ export const MultipleChoiceMultiQuestion = ({
useEffect(() => {
setOtherSelected(
!!value &&
((Array.isArray(value) ? value : [value]) as string[]).some((item) => {
return getChoicesWithoutOtherLabels().includes(item) === false;
Boolean(value) &&
(Array.isArray(value) ? value : [value]).some((item) => {
return !getChoicesWithoutOtherLabels().includes(item);
})
);
setOtherValue(
@@ -81,8 +82,8 @@ export const MultipleChoiceMultiQuestion = ({
}
if (question.shuffleOption === "none" || question.shuffleOption === undefined) return question.choices;
return shuffledChoicesIds.map((choiceId) => {
const choice = question.choices.find((choice) => {
return choice.id === choiceId;
const choice = question.choices.find((currentChoice) => {
return currentChoice.id === choiceId;
});
return choice;
});
@@ -115,19 +116,21 @@ export const MultipleChoiceMultiQuestion = ({
const newValue = value.filter((v) => {
return questionChoiceLabels.includes(v);
});
return onChange({ [question.id]: [...newValue, item] });
} else {
return onChange({ [question.id]: [...value, item] });
onChange({ [question.id]: [...newValue, item] });
return;
}
onChange({ [question.id]: [...value, item] });
return;
}
return onChange({ [question.id]: [item] }); // if not array, make it an array
onChange({ [question.id]: [item] }); // if not array, make it an array
};
const removeItem = (item: string) => {
if (Array.isArray(value)) {
return onChange({ [question.id]: value.filter((i) => i !== item) });
onChange({ [question.id]: value.filter((i) => i !== item) });
return;
}
return onChange({ [question.id]: [] }); // if not array, make it an array
onChange({ [question.id]: [] }); // if not array, make it an array
};
return (
@@ -135,7 +138,7 @@ export const MultipleChoiceMultiQuestion = ({
key={question.id}
onSubmit={(e) => {
e.preventDefault();
const newValue = (value as string[])?.filter((item) => {
const newValue = value.filter((item) => {
return getChoicesWithoutOtherLabels().includes(item) || item === otherValue;
}); // filter out all those values which are either in getChoicesWithoutOtherLabels() (i.e. selected by checkbox) or the latest entered otherValue
onChange({ [question.id]: newValue });
@@ -146,7 +149,9 @@ export const MultipleChoiceMultiQuestion = ({
className="fb-w-full">
<ScrollableContainer>
<div>
{isMediaAvailable && <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />}
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
@@ -191,7 +196,7 @@ export const MultipleChoiceMultiQuestion = ({
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={(e) => {
if ((e.target as HTMLInputElement)?.checked) {
if ((e.target as HTMLInputElement).checked) {
addItem(getLocalizedValue(choice.label, languageCode));
} else {
removeItem(getLocalizedValue(choice.label, languageCode));
@@ -214,7 +219,7 @@ export const MultipleChoiceMultiQuestion = ({
</label>
);
})}
{otherOption && (
{otherOption ? (
<label
tabIndex={isCurrent ? 0 : -1}
className={cn(
@@ -254,7 +259,7 @@ export const MultipleChoiceMultiQuestion = ({
{getLocalizedValue(otherOption.label, languageCode)}
</span>
</span>
{otherSelected && (
{otherSelected ? (
<input
ref={otherSpecify}
dir="auto"
@@ -273,9 +278,9 @@ export const MultipleChoiceMultiQuestion = ({
required={question.required}
aria-labelledby={`${otherOption.id}-label`}
/>
)}
) : null}
</label>
)}
) : null}
</div>
</fieldset>
</div>
@@ -302,4 +307,4 @@ export const MultipleChoiceMultiQuestion = ({
</div>
</form>
);
};
}

View File

@@ -1,14 +1,14 @@
import { BackButton } from "@/components/buttons/BackButton";
import { SubmitButton } from "@/components/buttons/SubmitButton";
import { Headline } from "@/components/general/Headline";
import { QuestionMedia } from "@/components/general/QuestionMedia";
import { Subheader } from "@/components/general/Subheader";
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn, getShuffledChoicesIds } from "@/lib/utils";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyMultipleChoiceQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
interface MultipleChoiceSingleProps {
@@ -26,7 +26,7 @@ interface MultipleChoiceSingleProps {
currentQuestionId: TSurveyQuestionId;
}
export const MultipleChoiceSingleQuestion = ({
export function MultipleChoiceSingleQuestion({
question,
value,
onChange,
@@ -39,7 +39,7 @@ export const MultipleChoiceSingleQuestion = ({
setTtc,
autoFocusEnabled,
currentQuestionId,
}: MultipleChoiceSingleProps) => {
}: MultipleChoiceSingleProps) {
const [startTime, setStartTime] = useState(performance.now());
const [otherSelected, setOtherSelected] = useState(false);
const otherSpecify = useRef<HTMLInputElement | null>(null);
@@ -49,20 +49,21 @@ export const MultipleChoiceSingleQuestion = ({
const shuffledChoicesIds = useMemo(() => {
if (question.shuffleOption) {
return getShuffledChoicesIds(question.choices, question.shuffleOption);
} else return question.choices.map((choice) => choice.id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}
return question.choices.map((choice) => choice.id);
// eslint-disable-next-line react-hooks/exhaustive-deps -- only want to run this effect when question.choices changes
}, [question.shuffleOption, question.choices.length, question.choices[question.choices.length - 1].id]);
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
const questionChoices = useMemo(() => {
if (!question.choices) {
if (!question.choices.length) {
return [];
}
if (question.shuffleOption === "none" || question.shuffleOption === undefined) return question.choices;
return shuffledChoicesIds.map((choiceId) => {
const choice = question.choices.find((choice) => {
return choice.id === choiceId;
const choice = question.choices.find((selectedChoice) => {
return selectedChoice.id === choiceId;
});
return choice;
});
@@ -109,7 +110,9 @@ export const MultipleChoiceSingleQuestion = ({
className="fb-w-full">
<ScrollableContainer>
<div>
{isMediaAvailable && <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />}
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
@@ -164,7 +167,7 @@ export const MultipleChoiceSingleQuestion = ({
onChange({ [question.id]: getLocalizedValue(choice.label, languageCode) });
}}
checked={value === getLocalizedValue(choice.label, languageCode)}
required={question.required && idx === 0}
required={question.required ? idx === 0 : undefined}
/>
<span id={`${choice.id}-label`} className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium">
{getLocalizedValue(choice.label, languageCode)}
@@ -173,7 +176,7 @@ export const MultipleChoiceSingleQuestion = ({
</label>
);
})}
{otherOption && (
{otherOption ? (
<label
dir="auto"
tabIndex={isCurrent ? 0 : -1}
@@ -214,7 +217,7 @@ export const MultipleChoiceSingleQuestion = ({
{getLocalizedValue(otherOption.label, languageCode)}
</span>
</span>
{otherSelected && (
{otherSelected ? (
<input
ref={otherSpecify}
id={`${otherOption.id}-label`}
@@ -226,14 +229,16 @@ export const MultipleChoiceSingleQuestion = ({
}}
className="placeholder:fb-text-placeholder fb-border-border fb-bg-survey-bg fb-text-heading focus:fb-ring-focus fb-rounded-custom fb-mt-3 fb-flex fb-h-10 fb-w-full fb-border fb-px-3 fb-py-2 fb-text-sm focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50"
placeholder={
getLocalizedValue(question.otherOptionPlaceholder, languageCode) ?? "Please specify"
getLocalizedValue(question.otherOptionPlaceholder, languageCode).length > 0
? getLocalizedValue(question.otherOptionPlaceholder, languageCode)
: "Please specify"
}
required={question.required}
aria-labelledby={`${otherOption.id}-label`}
/>
)}
) : null}
</label>
)}
) : null}
</div>
</fieldset>
</div>
@@ -259,4 +264,4 @@ export const MultipleChoiceSingleQuestion = ({
</div>
</form>
);
};
}

View File

@@ -1,14 +1,14 @@
import { BackButton } from "@/components/buttons/BackButton";
import { SubmitButton } from "@/components/buttons/SubmitButton";
import { Headline } from "@/components/general/Headline";
import { QuestionMedia } from "@/components/general/QuestionMedia";
import { Subheader } from "@/components/general/Subheader";
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn } from "@/lib/utils";
import { useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyNPSQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
interface NPSQuestionProps {
@@ -26,7 +26,7 @@ interface NPSQuestionProps {
currentQuestionId: TSurveyQuestionId;
}
export const NPSQuestion = ({
export function NPSQuestion({
question,
value,
onChange,
@@ -38,7 +38,7 @@ export const NPSQuestion = ({
ttc,
setTtc,
currentQuestionId,
}: NPSQuestionProps) => {
}: NPSQuestionProps) {
const [startTime, setStartTime] = useState(performance.now());
const [hoveredNumber, setHoveredNumber] = useState(-1);
const isMediaAvailable = question.imageUrl || question.videoUrl;
@@ -60,7 +60,9 @@ export const NPSQuestion = ({
};
const getNPSOptionColor = (idx: number) => {
return idx > 8 ? "fb-bg-emerald-100" : idx > 6 ? "fb-bg-orange-100" : "fb-bg-rose-100";
if (idx > 8) return "fb-bg-emerald-100";
if (idx > 6) return "fb-bg-orange-100";
return "fb-bg-rose-100";
};
return (
@@ -74,7 +76,9 @@ export const NPSQuestion = ({
}}>
<ScrollableContainer>
<div>
{isMediaAvailable && <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />}
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
@@ -93,8 +97,12 @@ export const NPSQuestion = ({
<label
key={number}
tabIndex={isCurrent ? 0 : -1}
onMouseOver={() => setHoveredNumber(number)}
onMouseLeave={() => setHoveredNumber(-1)}
onMouseOver={() => {
setHoveredNumber(number);
}}
onMouseLeave={() => {
setHoveredNumber(-1);
}}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
@@ -113,11 +121,11 @@ export const NPSQuestion = ({
: "fb-h fb-leading-10",
hoveredNumber === number ? "fb-bg-accent-bg" : ""
)}>
{question.isColorCodingEnabled && (
{question.isColorCodingEnabled ? (
<div
className={`fb-absolute fb-left-0 fb-top-0 fb-h-[6px] fb-w-full ${getNPSOptionColor(idx)}`}
/>
)}
) : null}
<input
type="radio"
id={number.toString()}
@@ -125,7 +133,9 @@ export const NPSQuestion = ({
value={number}
checked={value === number}
className="fb-absolute fb-left-0 fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
onClick={() => handleClick(number)}
onClick={() => {
handleClick(number);
}}
required={question.required}
tabIndex={-1}
/>
@@ -164,4 +174,4 @@ export const NPSQuestion = ({
</div>
</form>
);
};
}

View File

@@ -1,15 +1,14 @@
import { BackButton } from "@/components/buttons/BackButton";
import { SubmitButton } from "@/components/buttons/SubmitButton";
import { Headline } from "@/components/general/Headline";
import { QuestionMedia } from "@/components/general/QuestionMedia";
import { Subheader } from "@/components/general/Subheader";
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { RefObject } from "preact";
import { type RefObject } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TResponseData } from "@formbricks/types/responses";
import { TResponseTtc } from "@formbricks/types/responses";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyOpenTextQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
interface OpenTextQuestionProps {
@@ -28,7 +27,7 @@ interface OpenTextQuestionProps {
currentQuestionId: TSurveyQuestionId;
}
export const OpenTextQuestion = ({
export function OpenTextQuestion({
question,
value,
onChange,
@@ -41,7 +40,7 @@ export const OpenTextQuestion = ({
setTtc,
autoFocusEnabled,
currentQuestionId,
}: OpenTextQuestionProps) => {
}: OpenTextQuestionProps) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const isCurrent = question.id === currentQuestionId;
@@ -60,7 +59,7 @@ export const OpenTextQuestion = ({
};
const handleInputResize = (event: { target: any }) => {
let maxHeight = 160; // 8 lines
const maxHeight = 160; // 8 lines
const textarea = event.target;
textarea.style.height = "auto";
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
@@ -80,7 +79,9 @@ export const OpenTextQuestion = ({
className="fb-w-full">
<ScrollableContainer>
<div>
{isMediaAvailable && <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />}
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
@@ -94,17 +95,19 @@ export const OpenTextQuestion = ({
{question.longAnswer === false ? (
<input
ref={inputRef as RefObject<HTMLInputElement>}
autoFocus={isCurrent && autoFocusEnabled}
autoFocus={isCurrent ? autoFocusEnabled : undefined}
tabIndex={isCurrent ? 0 : -1}
name={question.id}
id={question.id}
placeholder={getLocalizedValue(question.placeholder, languageCode)}
dir="auto"
step={"any"}
step="any"
required={question.required}
value={value ? (value as string) : ""}
value={value ? value : ""}
type={question.inputType}
onInput={(e) => handleInputChange(e.currentTarget.value)}
onInput={(e) => {
handleInputChange(e.currentTarget.value);
}}
className="fb-border-border placeholder:fb-text-placeholder fb-text-subheading focus:fb-border-brand fb-bg-input-bg fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-outline-none focus:fb-ring-0 sm:fb-text-sm"
pattern={question.inputType === "phone" ? "[0-9+ ]+" : ".*"}
title={question.inputType === "phone" ? "Enter a valid phone number" : undefined}
@@ -113,7 +116,7 @@ export const OpenTextQuestion = ({
<textarea
ref={inputRef as RefObject<HTMLTextAreaElement>}
rows={3}
autoFocus={isCurrent && autoFocusEnabled}
autoFocus={isCurrent ? autoFocusEnabled : undefined}
name={question.id}
tabIndex={isCurrent ? 0 : -1}
aria-label="textarea"
@@ -121,7 +124,7 @@ export const OpenTextQuestion = ({
placeholder={getLocalizedValue(question.placeholder, languageCode)}
dir="auto"
required={question.required}
value={value as string}
value={value}
type={question.inputType}
onInput={(e) => {
handleInputChange(e.currentTarget.value);
@@ -156,4 +159,4 @@ export const OpenTextQuestion = ({
</div>
</form>
);
};
}

View File

@@ -1,14 +1,14 @@
import { BackButton } from "@/components/buttons/BackButton";
import { SubmitButton } from "@/components/buttons/SubmitButton";
import { Headline } from "@/components/general/Headline";
import { QuestionMedia } from "@/components/general/QuestionMedia";
import { Subheader } from "@/components/general/Subheader";
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn } from "@/lib/utils";
import { useEffect, useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyPictureSelectionQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
interface PictureSelectionProps {
@@ -26,7 +26,7 @@ interface PictureSelectionProps {
currentQuestionId: TSurveyQuestionId;
}
export const PictureSelectionQuestion = ({
export function PictureSelectionQuestion({
question,
value,
onChange,
@@ -38,7 +38,7 @@ export const PictureSelectionQuestion = ({
ttc,
setTtc,
currentQuestionId,
}: PictureSelectionProps) => {
}: PictureSelectionProps) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const isCurrent = question.id === currentQuestionId;
@@ -53,7 +53,7 @@ export const PictureSelectionQuestion = ({
values = [item];
}
return onChange({ [question.id]: values });
onChange({ [question.id]: values });
};
const removeItem = (item: string) => {
@@ -65,7 +65,7 @@ export const PictureSelectionQuestion = ({
values = [];
}
return onChange({ [question.id]: values });
onChange({ [question.id]: values });
};
const handleChange = (id: string) => {
@@ -80,7 +80,7 @@ export const PictureSelectionQuestion = ({
if (!question.allowMulti && value.length > 1) {
onChange({ [question.id]: [] });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps -- We only want to recompute when the allowMulti changes
}, [question.allowMulti]);
const questionChoices = question.choices;
@@ -97,7 +97,9 @@ export const PictureSelectionQuestion = ({
className="fb-w-full">
<ScrollableContainer>
<div>
{isMediaAvailable && <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />}
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
@@ -124,7 +126,9 @@ export const PictureSelectionQuestion = ({
document.getElementById(choice.id)?.focus();
}
}}
onClick={() => handleChange(choice.id)}
onClick={() => {
handleChange(choice.id);
}}
className={cn(
"fb-relative fb-w-full fb-cursor-pointer fb-overflow-hidden fb-border fb-rounded-custom focus:fb-outline-none fb-aspect-[4/3] fb-min-h-[7rem] fb-max-h-[50vh] focus:fb-border-brand focus:fb-border-4 group/image",
Array.isArray(value) && value.includes(choice.id)
@@ -143,7 +147,9 @@ export const PictureSelectionQuestion = ({
target="_blank"
title="Open in new tab"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
}}
className="fb-absolute fb-bottom-2 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-whitespace-nowrap fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-opacity-0 fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -153,9 +159,9 @@ export const PictureSelectionQuestion = ({
fill="none"
stroke="currentColor"
strokeWidth="1"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-expand">
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-expand">
<path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8" />
<path d="M3 16.2V21m0 0h4.8M3 21l6-6" />
<path d="M21 7.8V3m0 0h-4.8M21 3l-6 6" />
@@ -216,4 +222,4 @@ export const PictureSelectionQuestion = ({
</div>
</form>
);
};
}

View File

@@ -1,15 +1,15 @@
import { BackButton } from "@/components/buttons/BackButton";
import { SubmitButton } from "@/components/buttons/SubmitButton";
import { Headline } from "@/components/general/Headline";
import { QuestionMedia } from "@/components/general/QuestionMedia";
import { Subheader } from "@/components/general/Subheader";
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn, getShuffledChoicesIds } from "@/lib/utils";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useCallback, useMemo, useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type {
TSurveyQuestionChoice,
TSurveyQuestionId,
@@ -31,7 +31,7 @@ interface RankingQuestionProps {
currentQuestionId: TSurveyQuestionId;
}
export const RankingQuestion = ({
export function RankingQuestion({
question,
value,
onChange,
@@ -44,13 +44,14 @@ export const RankingQuestion = ({
setTtc,
autoFocusEnabled,
currentQuestionId,
}: RankingQuestionProps) => {
}: RankingQuestionProps) {
const [startTime, setStartTime] = useState(performance.now());
const isCurrent = question.id === currentQuestionId;
const shuffledChoicesIds = useMemo(() => {
if (question.shuffleOption) {
return getShuffledChoicesIds(question.choices, question.shuffleOption);
} else return question.choices.map((choice) => choice.id);
}
return question.choices.map((choice) => choice.id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [question.shuffleOption, question.choices.length]);
@@ -61,7 +62,7 @@ export const RankingQuestion = ({
useTtc(question.id, ttc, setTtc, startTime, setStartTime, isCurrent);
const [localValue, setLocalValue] = useState<string[]>(value || []);
const [localValue, setLocalValue] = useState<string[]>(value ?? []);
const sortedItems = useMemo(() => {
return localValue
@@ -72,9 +73,8 @@ export const RankingQuestion = ({
const unsortedItems = useMemo(() => {
if (question.shuffleOption === "all" && sortedItems.length === 0) {
return shuffledChoicesIds.map((id) => question.choices.find((c) => c.id === id));
} else {
return question.choices.filter((c) => !localValue.includes(c.id));
}
return question.choices.filter((c) => !localValue.includes(c.id));
}, [question.choices, question.shuffleOption, localValue, sortedItems, shuffledChoicesIds]);
const handleItemClick = useCallback(
@@ -144,7 +144,9 @@ export const RankingQuestion = ({
<form onSubmit={handleSubmit} className="fb-w-full">
<ScrollableContainer>
<div>
{isMediaAvailable && <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />}
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
@@ -180,7 +182,9 @@ export const RankingQuestion = ({
autoFocus={idx === 0 && autoFocusEnabled}>
<div
className="fb-flex fb-gap-x-4 fb-px-4 fb-items-center fb-grow fb-h-full group"
onClick={() => handleItemClick(item)}>
onClick={() => {
handleItemClick(item);
}}>
<span
className={cn(
"fb-w-6 fb-grow-0 fb-h-6 fb-flex fb-items-center fb-justify-center fb-rounded-full fb-text-xs fb-font-semibold fb-border-brand fb-border",
@@ -194,12 +198,14 @@ export const RankingQuestion = ({
{getLocalizedValue(item.label, languageCode)}
</div>
</div>
{isSorted && (
{isSorted ? (
<div className="fb-flex fb-flex-col fb-h-full fb-grow-0 fb-border-l fb-border-border">
<button
tabIndex={-1}
type="button"
onClick={() => handleMove(item.id, "up")}
onClick={() => {
handleMove(item.id, "up");
}}
className={cn(
"fb-px-2 fb-flex fb-flex-1 fb-items-center fb-justify-center",
isFirst
@@ -224,7 +230,9 @@ export const RankingQuestion = ({
<button
tabIndex={-1}
type="button"
onClick={() => handleMove(item.id, "down")}
onClick={() => {
handleMove(item.id, "down");
}}
className={cn(
"fb-px-2 fb-flex-1 fb-border-t fb-border-border fb-flex fb-items-center fb-justify-center",
isLast
@@ -247,14 +255,14 @@ export const RankingQuestion = ({
</svg>
</button>
</div>
)}
) : null}
</div>
);
})}
</div>
</fieldset>
</div>
{error && <div className="fb-text-red-500 fb-mt-2 fb-text-sm">{error}</div>}
{error ? <div className="fb-text-red-500 fb-mt-2 fb-text-sm">{error}</div> : null}
</div>
</ScrollableContainer>
@@ -274,4 +282,4 @@ export const RankingQuestion = ({
</div>
</form>
);
};
}

View File

@@ -1,14 +1,14 @@
import { BackButton } from "@/components/buttons/BackButton";
import { SubmitButton } from "@/components/buttons/SubmitButton";
import { Headline } from "@/components/general/Headline";
import { QuestionMedia } from "@/components/general/QuestionMedia";
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn } from "@/lib/utils";
import { useEffect, useState } from "preact/hooks";
import type { JSX } from "react";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyQuestionId, TSurveyRatingQuestion } from "@formbricks/types/surveys/types";
import {
ConfusedFace,
@@ -21,8 +21,8 @@ import {
SmilingFaceWithSmilingEyes,
TiredFace,
WearyFace,
} from "../general/Smileys";
import { Subheader } from "../general/Subheader";
} from "../general/smileys";
import { Subheader } from "../general/subheader";
interface RatingQuestionProps {
question: TSurveyRatingQuestion;
@@ -39,7 +39,7 @@ interface RatingQuestionProps {
currentQuestionId: TSurveyQuestionId;
}
export const RatingQuestion = ({
export function RatingQuestion({
question,
value,
onChange,
@@ -51,7 +51,7 @@ export const RatingQuestion = ({
ttc,
setTtc,
currentQuestionId,
}: RatingQuestionProps) => {
}: RatingQuestionProps) {
const [hoveredNumber, setHoveredNumber] = useState(0);
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
@@ -72,18 +72,22 @@ export const RatingQuestion = ({
}, 250);
};
const HiddenRadioInput = ({ number, id }: { number: number; id?: string }) => (
<input
type="radio"
id={id}
name="rating"
value={number}
className="fb-invisible fb-absolute fb-left-0 fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
onClick={() => handleSelect(number)}
required={question.required}
checked={value === number}
/>
);
function HiddenRadioInput({ number, id }: { number: number; id?: string }) {
return (
<input
type="radio"
id={id}
name="rating"
value={number}
className="fb-invisible fb-absolute fb-left-0 fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
onClick={() => {
handleSelect(number);
}}
required={question.required}
checked={value === number}
/>
);
}
useEffect(() => {
setHoveredNumber(0);
@@ -98,11 +102,10 @@ export const RatingQuestion = ({
if (range - idx < 1) return "fb-bg-emerald-100";
if (range - idx < 2) return "fb-bg-orange-100";
return "fb-bg-rose-100";
} else {
if (range - idx < 2) return "fb-bg-emerald-100";
if (range - idx < 3) return "fb-bg-orange-100";
return "fb-bg-rose-100";
}
if (range - idx < 2) return "fb-bg-emerald-100";
if (range - idx < 3) return "fb-bg-orange-100";
return "fb-bg-rose-100";
};
return (
@@ -117,7 +120,9 @@ export const RatingQuestion = ({
className="fb-w-full">
<ScrollableContainer>
<div>
{isMediaAvailable && <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />}
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
@@ -134,8 +139,12 @@ export const RatingQuestion = ({
{Array.from({ length: question.range }, (_, i) => i + 1).map((number, i, a) => (
<span
key={number}
onMouseOver={() => setHoveredNumber(number)}
onMouseLeave={() => setHoveredNumber(0)}
onMouseOver={() => {
setHoveredNumber(number);
}}
onMouseLeave={() => {
setHoveredNumber(0);
}}
className="fb-bg-survey-bg fb-flex-1 fb-text-center fb-text-sm">
{question.scale === "number" ? (
<label
@@ -158,11 +167,11 @@ export const RatingQuestion = ({
question.isColorCodingEnabled ? "fb-min-h-[47px]" : "fb-min-h-[41px]",
"fb-text-heading focus:fb-border-brand fb-relative fb-flex fb-w-full fb-cursor-pointer fb-items-center fb-justify-center fb-overflow-hidden fb-border-b fb-border-l fb-border-t focus:fb-border-2 focus:fb-outline-none"
)}>
{question.isColorCodingEnabled && (
{question.isColorCodingEnabled ? (
<div
className={`fb-absolute fb-left-0 fb-top-0 fb-h-[6px] fb-w-full ${getRatingNumberOptionColor(question.range, number)}`}
/>
)}
) : null}
<HiddenRadioInput number={number} id={number.toString()} />
{number}
</label>
@@ -178,14 +187,18 @@ export const RatingQuestion = ({
}
}}
className={cn(
number <= hoveredNumber || number <= (value as number)
number <= hoveredNumber || number <= value!
? "fb-text-amber-400"
: "fb-text-[#8696AC]",
hoveredNumber === number ? "fb-text-amber-400" : "",
"fb-relative fb-flex fb-max-h-16 fb-min-h-9 fb-cursor-pointer fb-justify-center focus:fb-outline-none"
)}
onFocus={() => setHoveredNumber(number)}
onBlur={() => setHoveredNumber(0)}>
onFocus={() => {
setHoveredNumber(number);
}}
onBlur={() => {
setHoveredNumber(0);
}}>
<HiddenRadioInput number={number} id={number.toString()} />
<div className="fb-h-full fb-w-full fb-max-w-[74px] fb-object-contain">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
@@ -213,8 +226,12 @@ export const RatingQuestion = ({
document.getElementById(number.toString())?.focus();
}
}}
onFocus={() => setHoveredNumber(number)}
onBlur={() => setHoveredNumber(0)}>
onFocus={() => {
setHoveredNumber(number);
}}
onBlur={() => {
setHoveredNumber(0);
}}>
<HiddenRadioInput number={number} id={number.toString()} />
<div className={cn("fb-h-full fb-w-full fb-max-w-[74px] fb-object-contain")}>
<RatingSmiley
@@ -249,7 +266,7 @@ export const RatingQuestion = ({
isLastQuestion={isLastQuestion}
/>
)}
<div></div>
<div />
{!isFirstQuestion && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
@@ -264,7 +281,7 @@ export const RatingQuestion = ({
</div>
</form>
);
};
}
interface RatingSmileyProps {
active: boolean;
@@ -282,11 +299,10 @@ const getSmileyColor = (range: number, idx: number) => {
if (range - idx < 2) return "fb-fill-emerald-100";
if (range - idx < 3) return "fb-fill-orange-100";
return "fb-fill-rose-100";
} else {
if (range - idx < 3) return "fb-fill-emerald-100";
if (range - idx < 4) return "fb-fill-orange-100";
return "fb-fill-rose-100";
}
if (range - idx < 3) return "fb-fill-emerald-100";
if (range - idx < 4) return "fb-fill-orange-100";
return "fb-fill-rose-100";
};
const getActiveSmileyColor = (range: number, idx: number) => {
@@ -298,11 +314,10 @@ const getActiveSmileyColor = (range: number, idx: number) => {
if (range - idx < 2) return "fb-fill-emerald-300";
if (range - idx < 3) return "fb-fill-orange-300";
return "fb-fill-rose-300";
} else {
if (range - idx < 3) return "fb-fill-emerald-300";
if (range - idx < 4) return "fb-fill-orange-300";
return "fb-fill-rose-300";
}
if (range - idx < 3) return "fb-fill-emerald-300";
if (range - idx < 4) return "fb-fill-orange-300";
return "fb-fill-rose-300";
};
const getSmiley = (iconIdx: number, idx: number, range: number, active: boolean, addColors: boolean) => {
@@ -310,18 +325,23 @@ const getSmiley = (iconIdx: number, idx: number, range: number, active: boolean,
const inactiveColor = addColors ? getSmileyColor(range, idx) : "fb-fill-none";
const icons = [
<TiredFace className={active ? activeColor : inactiveColor} />,
<WearyFace className={active ? activeColor : inactiveColor} />,
<PerseveringFace className={active ? activeColor : inactiveColor} />,
<FrowningFace className={active ? activeColor : inactiveColor} />,
<ConfusedFace className={active ? activeColor : inactiveColor} />,
<NeutralFace className={active ? activeColor : inactiveColor} />,
<SlightlySmilingFace className={active ? activeColor : inactiveColor} />,
<SmilingFaceWithSmilingEyes className={active ? activeColor : inactiveColor} />,
<GrinningFaceWithSmilingEyes className={active ? activeColor : inactiveColor} />,
<GrinningSquintingFace className={active ? activeColor : inactiveColor} />,
<TiredFace key="tired-face" className={active ? activeColor : inactiveColor} />,
<WearyFace key="weary-face" className={active ? activeColor : inactiveColor} />,
<PerseveringFace key="persevering-face" className={active ? activeColor : inactiveColor} />,
<FrowningFace key="frowning-face" className={active ? activeColor : inactiveColor} />,
<ConfusedFace key="confused-face" className={active ? activeColor : inactiveColor} />,
<NeutralFace key="neutral-face" className={active ? activeColor : inactiveColor} />,
<SlightlySmilingFace key="slightly-smiling-face" className={active ? activeColor : inactiveColor} />,
<SmilingFaceWithSmilingEyes
key="smiling-face-with-smiling-eyes"
className={active ? activeColor : inactiveColor}
/>,
<GrinningFaceWithSmilingEyes
key="grinning-face-with-smiling-eyes"
className={active ? activeColor : inactiveColor}
/>,
<GrinningSquintingFace key="grinning-squinting-face" className={active ? activeColor : inactiveColor} />,
];
return icons[iconIdx];
};

View File

@@ -1,16 +1,16 @@
import { AutoCloseProgressBar } from "@/components/general/AutoCloseProgressBar";
import { AutoCloseProgressBar } from "@/components/general/auto-close-progress-bar";
import React from "preact/compat";
import { useEffect, useRef, useState } from "preact/hooks";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
interface AutoCloseProps {
survey: TJsEnvironmentStateSurvey;
onClose: () => void;
onClose?: () => void;
offset: number;
children: React.ReactNode;
}
export const AutoCloseWrapper = ({ survey, onClose, children, offset }: AutoCloseProps) => {
export function AutoCloseWrapper({ survey, onClose, children, offset }: AutoCloseProps) {
const [countDownActive, setCountDownActive] = useState(true);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isAppSurvey = survey.type === "app";
@@ -24,7 +24,7 @@ export const AutoCloseWrapper = ({ survey, onClose, children, offset }: AutoClos
}
setCountDownActive(true);
timeoutRef.current = setTimeout(() => {
onClose();
onClose?.();
setCountDownActive(false);
}, survey.autoClose * 1000);
};
@@ -40,17 +40,17 @@ export const AutoCloseWrapper = ({ survey, onClose, children, offset }: AutoClos
useEffect(() => {
startCountdown();
return stopCountdown;
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want to run this effect on every render
}, [survey.autoClose]);
return (
<div className="fb-h-full fb-w-full">
{survey.autoClose && showAutoCloseProgressBar && (
{survey.autoClose && showAutoCloseProgressBar ? (
<AutoCloseProgressBar autoCloseTimeout={survey.autoClose} />
)}
) : null}
<div onClick={stopCountdown} onMouseOver={stopCountdown} className="fb-h-full fb-w-full">
{children}
</div>
</div>
);
};
}

View File

@@ -1,7 +1,7 @@
import { cn } from "@/lib/utils";
import { VNode } from "preact";
import { type VNode } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
import { TPlacement } from "@formbricks/types/common";
import { type TPlacement } from "@formbricks/types/common";
interface ModalProps {
children: VNode;
@@ -13,7 +13,7 @@ interface ModalProps {
onClose: () => void;
}
export const Modal = ({ children, isOpen, placement, clickOutside, darkOverlay, onClose }: ModalProps) => {
export function Modal({ children, isOpen, placement, clickOutside, darkOverlay, onClose }: ModalProps) {
const [show, setShow] = useState(false);
const isCenter = placement === "center";
const modalRef = useRef(null);
@@ -42,8 +42,8 @@ export const Modal = ({ children, isOpen, placement, clickOutside, darkOverlay,
}, [show, clickOutside, onClose, isCenter]);
// This classes will be applied only when screen size is greater than sm, hence sm is common prefix for all
const getPlacementStyle = (placement: TPlacement) => {
switch (placement) {
const getPlacementStyle = (contentPlacement: TPlacement) => {
switch (contentPlacement) {
case "bottomRight":
return "sm:fb-bottom-3 sm:fb-right-3";
case "topRight":
@@ -71,11 +71,9 @@ export const Modal = ({ children, isOpen, placement, clickOutside, darkOverlay,
<div
className={cn(
"fb-relative fb-h-full fb-w-full",
isCenter
? darkOverlay
? "fb-bg-slate-700/80"
: "fb-bg-white/50"
: "fb-bg-none fb-transition-all fb-duration-500 fb-ease-in-out"
!isCenter ? "fb-bg-none fb-transition-all fb-duration-500 fb-ease-in-out" : "",
isCenter && darkOverlay ? "fb-bg-slate-700/80" : "",
isCenter && !darkOverlay ? "fb-bg-white/50" : ""
)}>
<div
ref={modalRef}
@@ -89,4 +87,4 @@ export const Modal = ({ children, isOpen, placement, clickOutside, darkOverlay,
</div>
</div>
);
};
}

View File

@@ -1,18 +1,18 @@
import { cn } from "@/lib/utils";
import { useEffect, useRef, useState } from "preact/hooks";
import type { JSX } from "react";
import { cn } from "@/lib/utils";
interface ScrollableContainerProps {
children: JSX.Element;
}
export const ScrollableContainer = ({ children }: ScrollableContainerProps) => {
export function ScrollableContainer({ children }: ScrollableContainerProps) {
const [isOverflowHidden, setIsOverflowHidden] = useState(true);
const [isAtBottom, setIsAtBottom] = useState(false);
const [isAtTop, setIsAtTop] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const isSurveyPreview = !!document.getElementById("survey-preview");
const isSurveyPreview = Boolean(document.getElementById("survey-preview"));
const checkScroll = () => {
if (!containerRef.current) return;
@@ -26,7 +26,7 @@ export const ScrollableContainer = ({ children }: ScrollableContainerProps) => {
const toggleOverflow = (hide: boolean) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (hide) {
timeoutRef.current = setTimeout(() => setIsOverflowHidden(true), 1000);
timeoutRef.current = setTimeout(() => { setIsOverflowHidden(true); }, 1000);
} else {
setIsOverflowHidden(false);
checkScroll();
@@ -37,7 +37,7 @@ export const ScrollableContainer = ({ children }: ScrollableContainerProps) => {
const element = containerRef.current;
if (!element) return;
const handleScroll = () => checkScroll();
const handleScroll = () => { checkScroll(); };
element.addEventListener("scroll", handleScroll);
return () => {
@@ -52,7 +52,7 @@ export const ScrollableContainer = ({ children }: ScrollableContainerProps) => {
return (
<div className="fb-relative">
{!isAtTop && (
<div className="fb-from-survey-bg fb-absolute fb-left-0 fb-right-2 fb-top-0 fb-z-10 fb-h-4 fb-bg-gradient-to-b fb-to-transparent"></div>
<div className="fb-from-survey-bg fb-absolute fb-left-0 fb-right-2 fb-top-0 fb-z-10 fb-h-4 fb-bg-gradient-to-b fb-to-transparent" />
)}
<div
ref={containerRef}
@@ -64,13 +64,13 @@ export const ScrollableContainer = ({ children }: ScrollableContainerProps) => {
"fb-overflow-auto fb-px-4 fb-pb-1",
isOverflowHidden ? "fb-no-scrollbar" : "fb-bg-survey-bg"
)}
onMouseEnter={() => toggleOverflow(false)}
onMouseLeave={() => toggleOverflow(true)}>
onMouseEnter={() => { toggleOverflow(false); }}
onMouseLeave={() => { toggleOverflow(true); }}>
{children}
</div>
{!isAtBottom && (
<div className="fb-from-survey-bg fb-absolute -fb-bottom-2 fb-left-0 fb-right-2 fb-h-8 fb-bg-gradient-to-t fb-to-transparent"></div>
<div className="fb-from-survey-bg fb-absolute -fb-bottom-2 fb-left-0 fb-right-2 fb-h-8 fb-bg-gradient-to-t fb-to-transparent" />
)}
</div>
);
};
}

View File

@@ -1,10 +1,10 @@
import { cn } from "@/lib/utils";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import type { JSX } from "react";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TProjectStyling } from "@formbricks/types/project";
import { TCardArrangementOptions } from "@formbricks/types/styling";
import { TSurveyQuestionId, TSurveyStyling } from "@formbricks/types/surveys/types";
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { type TProjectStyling } from "@formbricks/types/project";
import { type TCardArrangementOptions } from "@formbricks/types/styling";
import { type TSurveyQuestionId, type TSurveyStyling } from "@formbricks/types/surveys/types";
// offset = 0 -> Current question card
// offset < 0 -> Question cards that are already answered
@@ -20,7 +20,7 @@ interface StackedCardsContainerProps {
fullSizeCards: boolean;
}
export const StackedCardsContainer = ({
export function StackedCardsContainer({
cardArrangement,
currentQuestionId,
survey,
@@ -29,11 +29,11 @@ export const StackedCardsContainer = ({
setQuestionId,
shouldResetQuestionId = true,
fullSizeCards = false,
}: StackedCardsContainerProps) => {
}: StackedCardsContainerProps) {
const [hovered, setHovered] = useState(false);
const highlightBorderColor =
survey.styling?.highlightBorderColor?.light || styling.highlightBorderColor?.light;
const cardBorderColor = survey.styling?.cardBorderColor?.light || styling.cardBorderColor?.light;
survey.styling?.highlightBorderColor?.light ?? styling.highlightBorderColor?.light;
const cardBorderColor = survey.styling?.cardBorderColor?.light ?? styling.cardBorderColor?.light;
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
const resizeObserver = useRef<ResizeObserver | null>(null);
const [cardHeight, setCardHeight] = useState("auto");
@@ -45,7 +45,7 @@ export const StackedCardsContainer = ({
return survey.questions.length;
}
return survey.questions.findIndex((question) => question.id === currentQuestionId);
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only update when currentQuestionId changes
}, [currentQuestionId, survey.welcomeCard.enabled, survey.questions.length]);
const [prevQuestionIdx, setPrevQuestionIdx] = useState(questionIdxTemp - 1);
@@ -75,7 +75,7 @@ export const StackedCardsContainer = ({
return prev;
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only update when questionIdxTemp changes
}, [questionIdxTemp]);
const borderStyles = useMemo(() => {
@@ -88,20 +88,29 @@ export const StackedCardsContainer = ({
survey.type === "link" || !highlightBorderColor ? cardBorderColor : highlightBorderColor;
return {
...baseStyle,
borderColor: borderColor,
borderColor,
};
}, [survey.type, cardBorderColor, highlightBorderColor]);
const calculateCardTransform = useMemo(() => {
const rotationCoefficient = cardWidth >= 1000 ? 1.5 : cardWidth > 650 ? 2 : 3;
let rotationCoefficient = 3;
if (cardWidth >= 1000) {
rotationCoefficient = 1.5;
} else if (cardWidth > 650) {
rotationCoefficient = 2;
}
return (offset: number) => {
switch (cardArrangement) {
case "casual":
return offset < 0
? `translateX(33%)`
: `translateX(0) rotate(-${(hovered ? rotationCoefficient : rotationCoefficient - 0.5) * offset}deg)`;
: `translateX(0) rotate(-${((hovered ? rotationCoefficient : rotationCoefficient - 0.5) * offset).toString()}deg)`;
case "straight":
return offset < 0 ? `translateY(25%)` : `translateY(-${(hovered ? 12 : 10) * offset}px)`;
return offset < 0
? `translateY(25%)`
: `translateY(-${((hovered ? 12 : 10) * offset).toString()}px)`;
default:
return offset < 0 ? `translateX(0)` : `translateX(0)`;
}
@@ -112,7 +121,7 @@ export const StackedCardsContainer = ({
if (cardArrangement === "straight") {
// styles to set the descending width of stacked question cards when card arrangement is set to straight
return {
width: `${100 - 5 * offset >= 100 ? 100 : 100 - 5 * offset}%`,
width: `${(100 - 5 * offset >= 100 ? 100 : 100 - 5 * offset).toString()}%`,
margin: "auto",
};
}
@@ -128,7 +137,7 @@ export const StackedCardsContainer = ({
}
resizeObserver.current = new ResizeObserver((entries) => {
for (const entry of entries) {
setCardHeight(entry.contentRect.height + "px");
setCardHeight(`${entry.contentRect.height.toString()}px`);
setCardWidth(entry.contentRect.width);
}
});
@@ -144,9 +153,9 @@ export const StackedCardsContainer = ({
// Reset question progress, when card arrangement changes
useEffect(() => {
if (shouldResetQuestionId) {
setQuestionId(survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id);
setQuestionId(survey.welcomeCard.enabled ? "start" : survey.questions[0]?.id);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only update when cardArrangement changes
}, [cardArrangement]);
const getCardHeight = (offset: number): string => {
@@ -155,7 +164,7 @@ export const StackedCardsContainer = ({
// Preserve original height
else if (offset < 0) return "initial";
// Assign the height of the foremost card to all cards behind it
else return cardHeight;
return cardHeight;
};
const getBottomStyles = () => {
@@ -171,11 +180,13 @@ export const StackedCardsContainer = ({
onMouseEnter={() => {
setHovered(true);
}}
onMouseLeave={() => setHovered(false)}>
<div style={{ height: cardHeight }}></div>
onMouseLeave={() => {
setHovered(false);
}}>
<div style={{ height: cardHeight }} />
{cardArrangement === "simple" ? (
<div
id={`questionCard-${questionIdxTemp}`}
id={`questionCard-${questionIdxTemp.toString()}`}
className={cn("fb-w-full fb-bg-survey-bg", fullSizeCards ? "fb-h-full" : "")}
style={{
...borderStyles,
@@ -185,20 +196,20 @@ export const StackedCardsContainer = ({
) : (
questionIdxTemp !== undefined &&
[prevQuestionIdx, currentQuestionIdx, nextQuestionIdx, nextQuestionIdx + 1].map(
(questionIdxTemp, index) => {
(dynamicQuestionIndex, index) => {
const hasEndingCard = survey.endings.length > 0;
// Check for hiding extra card
if (questionIdxTemp > survey.questions.length + (hasEndingCard ? 0 : -1)) return;
if (dynamicQuestionIndex > survey.questions.length + (hasEndingCard ? 0 : -1)) return;
const offset = index - 1;
const isHidden = offset < 0;
return (
<div
ref={(el) => (cardRefs.current[questionIdxTemp] = el)}
id={`questionCard-${questionIdxTemp}`}
key={questionIdxTemp}
ref={(el) => (cardRefs.current[dynamicQuestionIndex] = el)}
id={`questionCard-${dynamicQuestionIndex}`}
key={dynamicQuestionIndex}
style={{
zIndex: 1000 - questionIdxTemp,
transform: `${calculateCardTransform(offset)}`,
zIndex: 1000 - dynamicQuestionIndex,
transform: calculateCardTransform(offset),
opacity: isHidden ? 0 : (100 - 0 * offset) / 100,
height: fullSizeCards ? "100%" : getCardHeight(offset),
transitionDuration: "600ms",
@@ -208,7 +219,7 @@ export const StackedCardsContainer = ({
...getBottomStyles(),
}}
className="fb-pointer fb-rounded-custom fb-bg-survey-bg fb-absolute fb-inset-x-0 fb-backdrop-blur-md fb-transition-all fb-ease-in-out">
{getCardContent(questionIdxTemp, offset)}
{getCardContent(dynamicQuestionIndex, offset)}
</div>
);
}
@@ -216,4 +227,4 @@ export const StackedCardsContainer = ({
)}
</div>
);
};
}

View File

@@ -1,8 +1,8 @@
import { SurveyInline } from "@/components/general/SurveyInline";
import { SurveyModal } from "@/components/general/SurveyModal";
import { addCustomThemeToDom, addStylesToDom } from "@/lib/styles";
import { h, render } from "preact";
import { SurveyInlineProps, SurveyModalProps } from "@formbricks/types/formbricks-surveys";
import { type SurveyInlineProps, type SurveyModalProps } from "@formbricks/types/formbricks-surveys";
import { SurveyInline } from "@/components/general/survey-inline";
import { SurveyModal } from "@/components/general/survey-modal";
import { addCustomThemeToDom, addStylesToDom } from "@/lib/styles";
export const renderSurveyInline = (props: SurveyInlineProps) => {
addStylesToDom();

View File

@@ -2,8 +2,8 @@ import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { formatDateWithOrdinal, isValidDateString } from "@formbricks/lib/utils/datetime";
import { extractFallbackValue, extractId, extractRecallInfo } from "@formbricks/lib/utils/recall";
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
import { type TResponseData, type TResponseVariables } from "@formbricks/types/responses";
import { type TSurveyQuestion } from "@formbricks/types/surveys/types";
export const replaceRecallInfo = (
text: string,
@@ -55,7 +55,7 @@ export const parseRecallInformation = (
variables: TResponseVariables
) => {
const modifiedQuestion = structuredClone(question);
if (question.headline && question.headline[languageCode]?.includes("recall:")) {
if (question.headline[languageCode].includes("recall:")) {
modifiedQuestion.headline[languageCode] = replaceRecallInfo(
getLocalizedValue(modifiedQuestion.headline, languageCode),
responseData,
@@ -64,7 +64,7 @@ export const parseRecallInformation = (
}
if (
question.subheader &&
question.subheader[languageCode]?.includes("recall:") &&
question.subheader[languageCode].includes("recall:") &&
modifiedQuestion.subheader
) {
modifiedQuestion.subheader[languageCode] = replaceRecallInfo(

View File

@@ -3,8 +3,8 @@ import preflight from "@/styles/preflight.css?inline";
import calendarCss from "react-calendar/dist/Calendar.css?inline";
import datePickerCss from "react-date-picker/dist/DatePicker.css?inline";
import { isLight, mixColor } from "@formbricks/lib/utils/colors";
import { TProjectStyling } from "@formbricks/types/project";
import { TSurveyStyling } from "@formbricks/types/surveys/types";
import { type TProjectStyling } from "@formbricks/types/project";
import { type TSurveyStyling } from "@formbricks/types/surveys/types";
import editorCss from "../../../../apps/web/modules/ui/components/editor/styles-editor-frontend.css?inline";
import datePickerCustomCss from "../styles/date-picker.css?inline";
@@ -18,7 +18,7 @@ export const addStylesToDom = () => {
}
};
export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TSurveyStyling }) => {
export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TSurveyStyling }): void => {
// Check if the style element already exists
let styleElement = document.getElementById("formbricks__css__custom");
@@ -45,9 +45,9 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
// Use the helper function to append CSS variables
appendCssVariable("brand-color", styling.brandColor?.light);
appendCssVariable("focus-color", styling.brandColor?.light);
if (!!styling.brandColor?.light) {
if (styling.brandColor?.light) {
// If the brand color is defined, set the text color based on the lightness of the brand color
appendCssVariable("brand-text-color", isLight(styling.brandColor?.light) ? "black" : "white");
appendCssVariable("brand-text-color", isLight(styling.brandColor.light) ? "black" : "white");
} else {
// If the brand color is undefined, default to white
appendCssVariable("brand-text-color", "#ffffff");
@@ -62,37 +62,37 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
appendCssVariable("subheading-color", styling.questionColor?.light);
if (styling.questionColor?.light) {
appendCssVariable("placeholder-color", mixColor(styling.questionColor?.light, "#ffffff", 0.3));
appendCssVariable("placeholder-color", mixColor(styling.questionColor.light, "#ffffff", 0.3));
}
appendCssVariable("border-color", styling.inputBorderColor?.light);
if (styling.inputBorderColor?.light) {
appendCssVariable("border-color-highlight", mixColor(styling.inputBorderColor?.light, "#000000", 0.1));
appendCssVariable("border-color-highlight", mixColor(styling.inputBorderColor.light, "#000000", 0.1));
}
appendCssVariable("survey-background-color", styling.cardBackgroundColor?.light);
appendCssVariable("survey-border-color", styling.cardBorderColor?.light);
appendCssVariable("border-radius", `${roundness}px`);
appendCssVariable("border-radius", `${Number(roundness).toString()}px`);
appendCssVariable("input-background-color", styling.inputColor?.light);
if (styling.questionColor?.light) {
let signatureColor = "";
let brandingColor = "";
if (isLight(styling.questionColor?.light)) {
signatureColor = mixColor(styling.questionColor?.light, "#000000", 0.2);
brandingColor = mixColor(styling.questionColor?.light, "#000000", 0.3);
if (isLight(styling.questionColor.light)) {
signatureColor = mixColor(styling.questionColor.light, "#000000", 0.2);
brandingColor = mixColor(styling.questionColor.light, "#000000", 0.3);
} else {
signatureColor = mixColor(styling.questionColor?.light, "#ffffff", 0.2);
brandingColor = mixColor(styling.questionColor?.light, "#ffffff", 0.3);
signatureColor = mixColor(styling.questionColor.light, "#ffffff", 0.2);
brandingColor = mixColor(styling.questionColor.light, "#ffffff", 0.3);
}
appendCssVariable("signature-text-color", signatureColor);
appendCssVariable("branding-text-color", brandingColor);
}
if (!!styling.inputColor?.light) {
if (styling.inputColor?.light) {
if (
styling.inputColor.light === "#fff" ||
styling.inputColor.light === "#ffffff" ||
@@ -102,7 +102,7 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
} else {
appendCssVariable(
"input-background-color-selected",
mixColor(styling.inputColor?.light, "#000000", 0.025)
mixColor(styling.inputColor.light, "#000000", 0.025)
);
}
}

View File

@@ -1,21 +1,20 @@
import { useEffect } from "react";
import { TResponseTtc } from "@formbricks/types/responses";
import { TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { type TResponseTtc } from "@formbricks/types/responses";
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
export const getUpdatedTtc = (ttc: TResponseTtc, questionId: TSurveyQuestionId, time: number) => {
// Check if the question ID already exists
if (ttc.hasOwnProperty(questionId)) {
if (questionId in ttc) {
return {
...ttc,
[questionId]: ttc[questionId] + time,
};
} else {
// If the question ID does not exist, add it to the object
return {
...ttc,
[questionId]: time,
};
}
// If the question ID does not exist, add it to the object
return {
...ttc,
[questionId]: time,
};
};
export const useTtc = (

View File

@@ -1,17 +1,17 @@
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import {
TShuffleOption,
TSurveyLogic,
TSurveyLogicAction,
TSurveyQuestion,
TSurveyQuestionChoice,
type TShuffleOption,
type TSurveyLogic,
type TSurveyLogicAction,
type TSurveyQuestion,
type TSurveyQuestionChoice,
} from "@formbricks/types/surveys/types";
export const cn = (...classes: string[]) => {
return classes.filter(Boolean).join(" ");
};
const shuffle = (array: any[]) => {
const shuffle = (array: unknown[]) => {
for (let i = 0; i < array.length; i++) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
@@ -20,7 +20,7 @@ const shuffle = (array: any[]) => {
export const getShuffledRowIndices = (n: number, shuffleOption: TShuffleOption): number[] => {
// Create an array with numbers from 0 to n-1
let array = Array.from(Array(n).keys());
const array = Array.from(Array(n).keys());
if (shuffleOption === "all") {
shuffle(array);

10
pnpm-lock.yaml generated
View File

@@ -1008,6 +1008,9 @@ importers:
'@formkit/auto-animate':
specifier: 0.8.2
version: 0.8.2
react-calendar:
specifier: 5.1.0
version: 5.1.0(@types/react@18.3.11)(react-dom@19.0.0-rc-ed15d500-20241110(react@19.0.0-rc-ed15d500-20241110))(react@19.0.0-rc-ed15d500-20241110)
devDependencies:
'@calcom/embed-snippet':
specifier: 1.3.0
@@ -1027,6 +1030,9 @@ importers:
'@preact/preset-vite':
specifier: 2.9.0
version: 2.9.0(@babel/core@7.25.2)(preact@10.23.2)(vite@5.4.8(@types/node@22.3.0)(terser@5.31.6))
'@types/react':
specifier: 18.3.11
version: 18.3.11
autoprefixer:
specifier: 10.4.20
version: 10.4.20(postcss@8.4.41)
@@ -18225,7 +18231,7 @@ snapshots:
chalk: 4.1.2
execa: 5.1.1
fast-glob: 3.3.2
fast-xml-parser: 4.4.1
fast-xml-parser: 4.5.0
logkitty: 0.7.1
transitivePeerDependencies:
- encoding
@@ -18236,7 +18242,7 @@ snapshots:
chalk: 4.1.2
execa: 5.1.1
fast-glob: 3.3.2
fast-xml-parser: 4.4.1
fast-xml-parser: 4.5.0
ora: 5.4.1
transitivePeerDependencies:
- encoding