From 2e2f0fdbb51b6c23014744deed6b1ecc37fa69f3 Mon Sep 17 00:00:00 2001 From: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com> Date: Fri, 10 Jan 2025 21:03:17 +0530 Subject: [PATCH] chore: refactors question form input (#4567) --- .../edit/components/EditEndingCard.tsx | 7 +- .../edit/components/RedirectUrlForm.tsx | 76 ++- .../edit/components/SurveyMenuBar.tsx | 1 + .../components/language-indicator.tsx | 5 +- .../components/MultiLangWrapper.tsx | 101 +++ .../components/RecallItemSelect.tsx | 14 +- .../components/RecallWrapper.tsx | 309 +++++++++ .../components/QuestionFormInput/index.tsx | 600 +++++------------- .../src/components/general/ending-card.tsx | 9 +- packages/types/common.ts | 16 +- packages/types/surveys/types.ts | 39 +- 11 files changed, 721 insertions(+), 456 deletions(-) create mode 100644 apps/web/modules/surveys/components/QuestionFormInput/components/MultiLangWrapper.tsx create mode 100644 apps/web/modules/surveys/components/QuestionFormInput/components/RecallWrapper.tsx diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditEndingCard.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditEndingCard.tsx index b066fcc8a3..31ae31adb3 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditEndingCard.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditEndingCard.tsx @@ -281,7 +281,12 @@ export const EditEndingCard = ({ /> )} {endingCard.type === "redirectToUrl" && ( - + )} diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RedirectUrlForm.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RedirectUrlForm.tsx index 695a3f51e9..f6d872af2f 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RedirectUrlForm.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RedirectUrlForm.tsx @@ -1,26 +1,84 @@ +import { RecallWrapper } from "@/modules/surveys/components/QuestionFormInput/components/RecallWrapper"; import { Input } from "@/modules/ui/components/input"; import { Label } from "@/modules/ui/components/label"; import { useTranslations } from "next-intl"; -import { TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types"; +import { useRef } from "react"; +import { headlineToRecall, recallToHeadline } from "@formbricks/lib/utils/recall"; +import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; +import { TSurvey, TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types"; interface RedirectUrlFormProps { + localSurvey: TSurvey; endingCard: TSurveyRedirectUrlCard; updateSurvey: (input: Partial) => void; + contactAttributeKeys: TContactAttributeKey[]; } -export const RedirectUrlForm = ({ endingCard, updateSurvey }: RedirectUrlFormProps) => { +export const RedirectUrlForm = ({ + localSurvey, + contactAttributeKeys, + endingCard, + updateSurvey, +}: RedirectUrlFormProps) => { + const selectedLanguageCode = "default"; const t = useTranslations(); + const inputRef = useRef(null); + return (
- updateSurvey({ url: e.target.value })} + { + const updatedValue = { + ...endingCard, + url: recallItems && fallbacks ? headlineToRecall(val, recallItems, fallbacks) : val, + }; + + updateSurvey(updatedValue); + }} + onAddFallback={() => { + inputRef.current?.focus(); + }} + contactAttributeKeys={contactAttributeKeys} + isRecallAllowed + localSurvey={localSurvey} + usedLanguageCode={"default"} + render={({ value, onChange, highlightedJSX, children }) => { + return ( +
+ {/* The highlight container is absolutely positioned behind the input */} +
+ {highlightedJSX} +
+ onChange(e.target.value)} + /> + {children} +
+ ); + }} />
diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx index 5fa2922a99..23f7286d06 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx @@ -258,6 +258,7 @@ export const SurveyMenuBar = ({ } else { const errorMessage = getFormattedErrorMessage(updatedSurveyResponse); toast.error(errorMessage); + return false; } return true; diff --git a/apps/web/modules/ee/multi-language-surveys/components/language-indicator.tsx b/apps/web/modules/ee/multi-language-surveys/components/language-indicator.tsx index 460c8dc30d..dfd47b120a 100644 --- a/apps/web/modules/ee/multi-language-surveys/components/language-indicator.tsx +++ b/apps/web/modules/ee/multi-language-surveys/components/language-indicator.tsx @@ -44,7 +44,7 @@ export function LanguageIndicator({ }); return ( -
+
+ )} + + {showRecallItemSelect && ( + + )} + + {showFallbackInput && recallItems.length > 0 && ( + + )} +
+ ), + })} +
+ ); +}; diff --git a/apps/web/modules/surveys/components/QuestionFormInput/index.tsx b/apps/web/modules/surveys/components/QuestionFormInput/index.tsx index 293c79c86a..40973008d7 100644 --- a/apps/web/modules/surveys/components/QuestionFormInput/index.tsx +++ b/apps/web/modules/surveys/components/QuestionFormInput/index.tsx @@ -1,6 +1,7 @@ "use client"; -import { LanguageIndicator } from "@/modules/ee/multi-language-surveys/components/language-indicator"; +import { MultiLangWrapper } from "@/modules/surveys/components/QuestionFormInput/components/MultiLangWrapper"; +import { RecallWrapper } from "@/modules/surveys/components/QuestionFormInput/components/RecallWrapper"; import { Button } from "@/modules/ui/components/button"; import { FileInput } from "@/modules/ui/components/file-input"; import { Input } from "@/modules/ui/components/input"; @@ -8,28 +9,12 @@ import { Label } from "@/modules/ui/components/label"; import { TooltipRenderer } from "@/modules/ui/components/tooltip"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import { debounce } from "lodash"; -import { ImagePlusIcon, PencilIcon, TrashIcon } from "lucide-react"; +import { ImagePlusIcon, TrashIcon } from "lucide-react"; import { useTranslations } from "next-intl"; -import { type JSX, RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "react-hot-toast"; -import { - createI18nString, - extractLanguageCodes, - getEnabledLanguages, - getLocalizedValue, -} from "@formbricks/lib/i18n/utils"; -import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone"; +import { RefObject, useCallback, useMemo, useRef, useState } from "react"; +import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils"; import { useSyncScroll } from "@formbricks/lib/utils/hooks/useSyncScroll"; -import { - extractId, - extractRecallInfo, - findRecallInfoById, - getFallbackValues, - getRecallItems, - headlineToRecall, - recallToHeadline, - replaceRecallInfoWithUnderline, -} from "@formbricks/lib/utils/recall"; +import { recallToHeadline } from "@formbricks/lib/utils/recall"; import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; import { TI18nString, @@ -37,12 +22,9 @@ import { TSurveyEndScreenCard, TSurveyQuestion, TSurveyQuestionChoice, - TSurveyRecallItem, TSurveyRedirectUrlCard, } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; -import { FallbackInput } from "./components/FallbackInput"; -import { RecallItemSelect } from "./components/RecallItemSelect"; import { determineImageUploaderVisibility, getChoiceLabel, @@ -116,11 +98,6 @@ export const QuestionFormInput = ({ : question.id; }, [isWelcomeCard, isEndingCard, question?.id]); - const enabledLanguages = useMemo( - () => getEnabledLanguages(localSurvey.languages ?? []), - [localSurvey.languages] - ); - const surveyLanguageCodes = useMemo( () => extractLanguageCodes(localSurvey.languages), [localSurvey.languages] @@ -171,140 +148,16 @@ export const QuestionFormInput = ({ ]); const [text, setText] = useState(elementText); - const [renderedText, setRenderedText] = useState(); const [showImageUploader, setShowImageUploader] = useState( determineImageUploaderVisibility(questionIdx, localSurvey) ); - const [showRecallItemSelect, setShowRecallItemSelect] = useState(false); - const [showFallbackInput, setShowFallbackInput] = useState(false); - const [recallItems, setRecallItems] = useState( - getLocalizedValue(text, usedLanguageCode).includes("#recall:") - ? getRecallItems( - getLocalizedValue(text, usedLanguageCode), - localSurvey, - usedLanguageCode, - contactAttributeKeys - ) - : [] - ); - - const [fallbacks, setFallbacks] = useState<{ [type: string]: string }>(() => { - const localizedValue = getLocalizedValue(text, usedLanguageCode); - return localizedValue.includes("/fallback:") ? getFallbackValues(localizedValue) : {}; - }); const highlightContainerRef = useRef(null); - const fallbackInputRef = useRef(null); const inputRef = useRef(null); - const filteredRecallItems = Array.from(new Set(recallItems.map((q) => q.id))).map((id) => { - return recallItems.find((q) => q.id === id); - }); - // Hook to synchronize the horizontal scroll position of highlightContainerRef and inputRef. useSyncScroll(highlightContainerRef, inputRef); - useEffect(() => { - setRecallItems( - getLocalizedValue(text, usedLanguageCode).includes("#recall:") - ? getRecallItems( - getLocalizedValue(text, usedLanguageCode), - localSurvey, - usedLanguageCode, - contactAttributeKeys - ) - : [] - ); - }, [usedLanguageCode]); - - useEffect(() => { - // Generates an array of headlines from recallItems, replacing nested recall questions with '___' . - const recallItemLabels = recallItems.flatMap((recallItem) => { - if (!recallItem.label.includes("#recall:")) { - return [recallItem.label]; - } - const recallItemLabel = recallItem.label; - const recallInfo = extractRecallInfo(recallItemLabel); - - if (recallInfo) { - const recallItemId = extractId(recallInfo); - const recallQuestion = localSurvey.questions.find((question) => question.id === recallItemId); - - if (recallQuestion) { - return [recallItemLabel.replace(recallInfo, `___`)]; - } - } - return []; - }); - - // Constructs an array of JSX elements representing segmented parts of text, interspersed with special formatted spans for recall headlines. - const processInput = (): JSX.Element[] => { - const parts: JSX.Element[] = []; - let remainingText = recallToHeadline(text, localSurvey, false, usedLanguageCode, contactAttributeKeys)[ - usedLanguageCode - ]; - filterRecallItems(remainingText); - recallItemLabels.forEach((label) => { - const index = remainingText.indexOf("@" + label); - if (index !== -1) { - if (index > 0) { - parts.push( - - {remainingText.substring(0, index)} - - ); - } - parts.push( - - {"@" + label} - - ); - remainingText = remainingText.substring(index + label.length + 1); - } - }); - if (remainingText?.length) { - parts.push( - - {remainingText} - - ); - } - return parts; - }; - - setRenderedText(processInput()); - }, [text, recallItems]); - - useEffect(() => { - if (fallbackInputRef.current) { - fallbackInputRef.current.focus(); - } - }, [showFallbackInput]); - - // useEffect(() => { - // setText(getElementTextBasedOnType()); - // }, [localSurvey]); - - const checkForRecallSymbol = useCallback( - (value: TI18nString) => { - const pattern = /(^|\s)@(\s|$)/; - if (pattern.test(getLocalizedValue(value, usedLanguageCode))) { - setShowRecallItemSelect(true); - } else { - setShowRecallItemSelect(false); - } - }, - [usedLanguageCode] - ); - - // updation of questions, WelcomeCard, ThankYouCard and choices is done in a different manner, - // questions -> updateQuestion - // thankYouCard, welcomeCard-> updateSurvey - // choice -> updateChoice - // matrixLabel -> updateMatrixLabel - const createUpdatedText = useCallback( (updatedText: string): TI18nString => { return { @@ -391,103 +244,6 @@ export const QuestionFormInput = ({ ] ); - // Adds a new recall question to the recallItems array, updates fallbacks, modifies the text with recall details. - const addRecallItem = useCallback( - (recallItem: TSurveyRecallItem) => { - if (recallItem.label.trim() === "") { - toast.error(t("environments.surveys.edit.cannot_add_question_with_empty_headline_as_recall")); - return; - } - - let recallItemTemp = structuredClone(recallItem); - recallItemTemp.label = replaceRecallInfoWithUnderline(recallItem.label); - - setRecallItems((prevQuestions) => { - const updatedQuestions = [...prevQuestions, recallItemTemp]; - return updatedQuestions; - }); - - if (!Object.keys(fallbacks).includes(recallItem.id)) { - setFallbacks((prevFallbacks) => ({ - ...prevFallbacks, - [recallItem.id]: "", - })); - } - - setShowRecallItemSelect(false); - - let modifiedHeadlineWithId = { ...elementText }; - modifiedHeadlineWithId[usedLanguageCode] = getLocalizedValue( - modifiedHeadlineWithId, - usedLanguageCode - ).replace(/(?<=^|\s)@(?=\s|$)/g, `#recall:${recallItem.id}/fallback:# `); - - handleUpdate(getLocalizedValue(modifiedHeadlineWithId, usedLanguageCode)); - - const modifiedHeadlineWithName = recallToHeadline( - modifiedHeadlineWithId, - localSurvey, - false, - usedLanguageCode, - contactAttributeKeys - ); - - setText(modifiedHeadlineWithName); - setShowFallbackInput(true); - }, - [contactAttributeKeys, elementText, fallbacks, handleUpdate, localSurvey, usedLanguageCode] - ); - - // Filters and updates the list of recall questions based on their presence in the given text, also managing related text and fallback states. - const filterRecallItems = useCallback( - (remainingText: string) => { - let includedRecallItems: TSurveyRecallItem[] = []; - - recallItems.forEach((recallItem) => { - if (remainingText.includes(`@${recallItem.label}`)) { - includedRecallItems.push(recallItem); - } else { - const recallItemToRemove = recallItem.label.slice(0, -1); - const newText = { ...text }; - newText[usedLanguageCode] = text[usedLanguageCode].replace(`@${recallItemToRemove}`, ""); - setText(newText); - handleUpdate(text[usedLanguageCode].replace(`@${recallItemToRemove}`, "")); - let updatedFallback = { ...fallbacks }; - delete updatedFallback[recallItem.id]; - setFallbacks(updatedFallback); - setRecallItems(includedRecallItems); - } - }); - }, - [fallbacks, handleUpdate, recallItems, text, usedLanguageCode] - ); - - const addFallback = () => { - let headlineWithFallback = elementText; - filteredRecallItems.forEach((recallQuestion) => { - if (recallQuestion) { - const recallInfo = findRecallInfoById( - getLocalizedValue(headlineWithFallback, usedLanguageCode), - recallQuestion!.id - ); - if (recallInfo) { - let fallBackValue = fallbacks[recallQuestion.id].trim(); - fallBackValue = fallBackValue.replace(/ /g, "nbsp"); - let updatedFallback = { ...fallbacks }; - updatedFallback[recallQuestion.id] = fallBackValue; - setFallbacks(updatedFallback); - headlineWithFallback[usedLanguageCode] = getLocalizedValue( - headlineWithFallback, - usedLanguageCode - ).replace(recallInfo, `#recall:${recallQuestion?.id}/fallback:${fallBackValue}#`); - handleUpdate(getLocalizedValue(headlineWithFallback, usedLanguageCode)); - } - } - }); - setShowFallbackInput(false); - inputRef.current?.focus(); - }; - const getFileUrl = (): string | undefined => { if (isWelcomeCard) return localSurvey.welcomeCard.fileUrl; if (isEndingCard) { @@ -504,197 +260,173 @@ export const QuestionFormInput = ({ } else return question.videoUrl; }; - const debouncedHandleUpdate = useMemo( - () => debounce((value) => handleUpdate(headlineToRecall(value, recallItems, fallbacks)), 100), - [handleUpdate, recallItems, fallbacks] - ); - - const handleInputChange = (e: React.ChangeEvent) => { - const value = e.target.value; - const updatedText = { - ...elementText, - [usedLanguageCode]: value, - }; - - const valueTI18nString = recallToHeadline( - updatedText, - localSurvey, - false, - usedLanguageCode, - contactAttributeKeys - ); - - setText(valueTI18nString); - - if (id === "headline" || id === "subheader") { - checkForRecallSymbol(valueTI18nString); - } - - debouncedHandleUpdate(value); - }; + const debouncedHandleUpdate = useMemo(() => debounce((value) => handleUpdate(value), 100), [handleUpdate]); const [animationParent] = useAutoAnimate(); return (
-
- {label && ( -
- -
- )} - -
- {showImageUploader && id === "headline" && ( - { - if (url) { - const update = - fileType === "video" - ? { videoUrl: url[0], imageUrl: "" } - : { imageUrl: url[0], videoUrl: "" }; - if (isEndingCard && updateSurvey) { - updateSurvey(update); - } else if (updateQuestion) { - updateQuestion(questionIdx, update); - } - } + {label && ( +
+ +
+ )} + { + setText(updatedText); + debouncedHandleUpdate(updatedText[usedLanguageCode]); + }} + contactAttributeKeys={contactAttributeKeys} + render={({ value, onChange, children: languageIndicator }) => { + return ( + { + // Pass all values to MultiLangWrapper's onChange + onChange(value, recallItems, fallbacks); + }} + onAddFallback={() => { + inputRef.current?.focus(); + }} + isRecallAllowed={!isWelcomeCard && (id === "headline" || id === "subheader")} + usedLanguageCode={usedLanguageCode} + render={({ + value, + onChange, + highlightedJSX, + children: recallComponents, + isRecallSelectVisible, + }) => { + return ( +
+ {showImageUploader && id === "headline" && ( + { + if (url) { + const update = + fileType === "video" + ? { videoUrl: url[0], imageUrl: "" } + : { imageUrl: url[0], videoUrl: "" }; + if (isEndingCard && updateSurvey) { + updateSurvey(update); + } else if (updateQuestion) { + updateQuestion(questionIdx, update); + } + } + }} + fileUrl={getFileUrl()} + videoUrl={getVideoUrl()} + isVideoAllowed={true} + /> + )} + +
+
+ {languageIndicator} + {/* The highlight container is absolutely positioned behind the input */} +
+
1 ? "pr-24" : "" + }`} + dir="auto" + key={highlightedJSX.toString()}> + {highlightedJSX} +
+ + onChange(e.target.value)} + id={id} + name={id} + placeholder={placeholder ?? getPlaceHolderById(id, t)} + aria-label={label} + maxLength={maxLength} + ref={inputRef} + onBlur={onBlur} + className={`absolute top-0 text-black caret-black ${ + localSurvey.languages?.length > 1 ? "pr-24" : "" + } ${className}`} + isInvalid={ + isInvalid && + text[usedLanguageCode]?.trim() === "" && + localSurvey.languages?.length > 1 && + isTranslationIncomplete + } + autoComplete={isRecallSelectVisible ? "off" : "on"} + autoFocus={id === "headline"} + /> + {recallComponents} +
+ +
+ {id === "headline" && !isWelcomeCard && ( + + + + )} + {id === "subheader" && question && question.subheader !== undefined && ( + + + + )} +
+
+
+ ); }} - fileUrl={getFileUrl()} - videoUrl={getVideoUrl()} - isVideoAllowed={true} /> - )} -
-
-
-
1 ? "pr-24" : "" - }`} - dir="auto"> - {renderedText} -
- {getLocalizedValue(elementText, usedLanguageCode).includes("recall:") && ( - - )} - 1 ? "pr-24" : "" - } ${className}`} - placeholder={placeholder ? placeholder : getPlaceHolderById(id, t)} - id={id} - name={id} - aria-label={label} - autoComplete={showRecallItemSelect ? "off" : "on"} - value={ - recallToHeadline(text, localSurvey, false, usedLanguageCode, contactAttributeKeys)[ - usedLanguageCode - ] - } - onChange={handleInputChange} - ref={inputRef} - onBlur={onBlur} - maxLength={maxLength ?? undefined} - autoFocus={id === "headline"} - isInvalid={ - isInvalid && - text[usedLanguageCode]?.trim() === "" && - localSurvey.languages?.length > 1 && - isTranslationIncomplete - } - /> - {enabledLanguages.length > 1 && ( - - )} - {!showRecallItemSelect && showFallbackInput && recallItems.length > 0 && ( - - )} -
- {id === "headline" && !isWelcomeCard && ( - - - - )} - {id === "subheader" && question && question.subheader !== undefined && ( - - - - )} -
-
- {showRecallItemSelect && ( - - )} -
- {usedLanguageCode !== "default" && value && typeof value["default"] !== undefined && ( -
- {t("environments.project.languages.translate")}:{" "} - {recallToHeadline(value, localSurvey, false, "default", contactAttributeKeys)["default"]} -
- )} - {usedLanguageCode === "default" && localSurvey.languages?.length > 1 && isTranslationIncomplete && ( -
- {t("environments.project.languages.incomplete_translations")} -
- )} + ); + }} + />
); }; diff --git a/packages/surveys/src/components/general/ending-card.tsx b/packages/surveys/src/components/general/ending-card.tsx index 8156898985..f72607f8c4 100644 --- a/packages/surveys/src/components/general/ending-card.tsx +++ b/packages/surveys/src/components/general/ending-card.tsx @@ -66,7 +66,14 @@ export function EndingCard({ useEffect(() => { if (isCurrent) { if (!isRedirectDisabled && endingCard.type === "redirectToUrl" && endingCard.url) { - window.top?.location.replace(endingCard.url); + try { + const url = replaceRecallInfo(endingCard.url, responseData, variablesData); + if (url && new URL(url)) { + window.top?.location.replace(url); + } + } catch (error) { + console.error("Invalid URL after recall processing:", error); + } } } const handleEnter = (e: KeyboardEvent) => { diff --git a/packages/types/common.ts b/packages/types/common.ts index 7c3459fcd2..a8701700e7 100644 --- a/packages/types/common.ts +++ b/packages/types/common.ts @@ -53,6 +53,18 @@ export const getZSafeUrl = (message: string): z.ZodEffects url.startsWith("https://"), { - message: "URL must start with https://", + .superRefine((url, ctx) => { + if (url.includes(" ")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "URL must not contain spaces", + }); + } + + if (!url.startsWith("https://")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "URL must start with https://", + }); + } }); diff --git a/packages/types/surveys/types.ts b/packages/types/surveys/types.ts index 626fe508dd..6f24609300 100644 --- a/packages/types/surveys/types.ts +++ b/packages/types/surveys/types.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-new -- required for error */ import { type ZodIssue, z } from "zod"; import { ZSurveyFollowUp } from "@formbricks/database/types/survey-follow-up"; import { ZActionClass, ZActionClassNoCodeConfig } from "../action-classes"; @@ -38,9 +39,45 @@ export const ZSurveyEndScreenCard = ZSurveyEndingBase.extend({ export type TSurveyEndScreenCard = z.infer; +const validateUrlWithRecall = (url: string): string | null => { + try { + if (!url.startsWith("https://")) { + return "URL must start with https://"; + } + + if (url.includes(" ") && !url.endsWith(" ")) { + return "URL must not contain spaces"; + } + + new URL(url); + + return null; + } catch { + const hostname = url.split("https://")[1]; + if (hostname.includes("#recall:")) { + return "Recall information cannot be used in the hostname part of the URL"; + } + + return "Invalid Redirect URL"; + } +}; + export const ZSurveyRedirectUrlCard = ZSurveyEndingBase.extend({ type: z.literal("redirectToUrl"), - url: getZSafeUrl("Invalid Redirect Url").optional(), + url: z + .string() + .optional() + .superRefine((url, ctx) => { + if (!url) return; + + const error = validateUrlWithRecall(url); + if (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: error, + }); + } + }), label: z.string().optional(), });