From 0e898db710fd85f2578a5c90f1e0779d12d9617f Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Tue, 4 Mar 2025 20:28:00 +0530 Subject: [PATCH] chore: Remove lib dependency from survey package (#4767) Co-authored-by: Piyush Gupta --- .../src/scripts/generate-data-migration.ts | 2 +- .../database/src/scripts/migration-runner.ts | 2 +- packages/lib/utils/colors.ts | 30 +- packages/lib/utils/datetime.ts | 57 +-- packages/lib/utils/videoUpload.ts | 4 +- .../src/components/survey-web-view.tsx | 6 +- .../src/components/general/ending-card.tsx | 2 +- .../src/components/general/file-input.tsx | 4 +- .../components/general/language-switch.tsx | 2 +- .../general/question-conditional.tsx | 2 +- .../src/components/general/question-media.tsx | 7 +- .../general/response-error-component.tsx | 2 +- .../surveys/src/components/general/survey.tsx | 2 +- .../src/components/general/welcome-card.tsx | 2 +- .../components/questions/address-question.tsx | 2 +- .../src/components/questions/cal-question.tsx | 2 +- .../components/questions/consent-question.tsx | 2 +- .../questions/contact-info-question.tsx | 2 +- .../src/components/questions/cta-question.tsx | 2 +- .../components/questions/date-question.tsx | 4 +- .../questions/file-upload-question.tsx | 2 +- .../components/questions/matrix-question.tsx | 2 +- .../multiple-choice-multi-question.tsx | 2 +- .../multiple-choice-single-question.tsx | 2 +- .../src/components/questions/nps-question.tsx | 2 +- .../questions/open-text-question.tsx | 2 +- .../questions/picture-selection-question.tsx | 4 +- .../components/questions/ranking-question.tsx | 2 +- .../components/questions/rating-question.tsx | 2 +- .../components/wrappers/survey-container.tsx | 2 +- packages/surveys/src/lib/color.ts | 53 ++ packages/surveys/src/lib/date-time.ts | 48 ++ packages/surveys/src/lib/i18n.ts | 19 + packages/surveys/src/lib/logic.ts | 473 ++++++++++++++++++ packages/surveys/src/lib/recall.ts | 38 +- packages/surveys/src/lib/response.ts | 28 ++ packages/surveys/src/lib/storage.ts | 22 + packages/surveys/src/lib/styles.ts | 2 +- .../surveys/src/lib/use-click-outside-hook.ts | 36 ++ packages/surveys/src/lib/utils.ts | 38 +- packages/surveys/src/lib/video-upload.ts | 127 +++++ 41 files changed, 889 insertions(+), 155 deletions(-) create mode 100644 packages/surveys/src/lib/color.ts create mode 100644 packages/surveys/src/lib/date-time.ts create mode 100644 packages/surveys/src/lib/i18n.ts create mode 100644 packages/surveys/src/lib/logic.ts create mode 100644 packages/surveys/src/lib/response.ts create mode 100644 packages/surveys/src/lib/storage.ts create mode 100644 packages/surveys/src/lib/use-click-outside-hook.ts create mode 100644 packages/surveys/src/lib/video-upload.ts diff --git a/packages/database/src/scripts/generate-data-migration.ts b/packages/database/src/scripts/generate-data-migration.ts index a9ea29b00c..dfd923829b 100644 --- a/packages/database/src/scripts/generate-data-migration.ts +++ b/packages/database/src/scripts/generate-data-migration.ts @@ -1,7 +1,7 @@ -import { createId } from "@paralleldrive/cuid2"; import fs from "node:fs/promises"; import path from "node:path"; import readline from "node:readline"; +import { createId } from "@paralleldrive/cuid2"; const rl = readline.createInterface({ input: process.stdin, diff --git a/packages/database/src/scripts/migration-runner.ts b/packages/database/src/scripts/migration-runner.ts index 5a48553c90..b0d020ec6b 100644 --- a/packages/database/src/scripts/migration-runner.ts +++ b/packages/database/src/scripts/migration-runner.ts @@ -1,8 +1,8 @@ -import { type Prisma, PrismaClient } from "@prisma/client"; import { exec } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; +import { type Prisma, PrismaClient } from "@prisma/client"; const execAsync = promisify(exec); diff --git a/packages/lib/utils/colors.ts b/packages/lib/utils/colors.ts index 9f11e68947..5f8ba6d343 100644 --- a/packages/lib/utils/colors.ts +++ b/packages/lib/utils/colors.ts @@ -1,4 +1,4 @@ -export const hexToRGBA = (hex: string | undefined, opacity: number): string | undefined => { +const hexToRGBA = (hex: string | undefined, opacity: number): string | undefined => { // return undefined if hex is undefined, this is important for adding the default values to the CSS variables // TODO: find a better way to handle this if (!hex || hex === "") return undefined; @@ -17,34 +17,6 @@ export const hexToRGBA = (hex: string | undefined, opacity: number): string | un return `rgba(${r}, ${g}, ${b}, ${opacity})`; }; -export const lightenDarkenColor = (hexColor: string, magnitude: number): string => { - hexColor = hexColor.replace(`#`, ``); - - // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") - if (hexColor.length === 3) { - hexColor = hexColor - .split("") - .map((char) => char + char) - .join(""); - } - - if (hexColor.length === 6) { - let decimalColor = parseInt(hexColor, 16); - let r = (decimalColor >> 16) + magnitude; - r = Math.max(0, Math.min(255, r)); // Clamp value between 0 and 255 - let g = ((decimalColor >> 8) & 0x00ff) + magnitude; - g = Math.max(0, Math.min(255, g)); // Clamp value between 0 and 255 - let b = (decimalColor & 0x0000ff) + magnitude; - b = Math.max(0, Math.min(255, b)); // Clamp value between 0 and 255 - - // Convert back to hex and return - return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; - } else { - // Return the original color if it's neither 3 nor 6 characters - return hexColor; - } -}; - export const mixColor = (hexColor: string, mixWithHex: string, weight: number): string => { // Convert both colors to RGBA format const color1 = hexToRGBA(hexColor, 1) || ""; diff --git a/packages/lib/utils/datetime.ts b/packages/lib/utils/datetime.ts index f86a91b79a..1f3d866081 100644 --- a/packages/lib/utils/datetime.ts +++ b/packages/lib/utils/datetime.ts @@ -1,17 +1,8 @@ -const monthNames = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", -]; +const getOrdinalSuffix = (day: number) => { + const suffixes = ["th", "st", "nd", "rd"]; + const relevantDigits = day < 30 ? day % 20 : day % 30; + return suffixes[relevantDigits <= 3 ? relevantDigits : 0]; +}; // Helper function to calculate difference in days between two dates export const diffInDays = (date1: Date, date2: Date) => { @@ -19,42 +10,12 @@ export const diffInDays = (date1: Date, date2: Date) => { return Math.floor(diffTime / (1000 * 60 * 60 * 24)); }; -// Helper function to get the month name -export const getMonthName = (monthIndex: number) => { - return monthNames[monthIndex]; -}; - -export const formatDateWithOrdinal = (date: Date): string => { - const getOrdinalSuffix = (day: number) => { - const suffixes = ["th", "st", "nd", "rd"]; - const relevantDigits = day < 30 ? day % 20 : day % 30; - return suffixes[relevantDigits <= 3 ? relevantDigits : 0]; - }; - - const dayOfWeekNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - - const dayOfWeek = dayOfWeekNames[date.getDay()]; +export const formatDateWithOrdinal = (date: Date, locale: string = "en-US"): string => { + const dayOfWeek = new Intl.DateTimeFormat(locale, { weekday: "long" }).format(date); const day = date.getDate(); - const monthIndex = date.getMonth(); + const month = new Intl.DateTimeFormat(locale, { month: "long" }).format(date); const year = date.getFullYear(); - - return `${dayOfWeek}, ${monthNames[monthIndex]} ${day}${getOrdinalSuffix(day)}, ${year}`; -}; - -// Helper function to format the date with an ordinal suffix -export const getOrdinalDate = (date: number) => { - const j = date % 10, - k = date % 100; - if (j === 1 && k !== 11) { - return date + "st"; - } - if (j === 2 && k !== 12) { - return date + "nd"; - } - if (j === 3 && k !== 13) { - return date + "rd"; - } - return date + "th"; + return `${dayOfWeek}, ${month} ${day}${getOrdinalSuffix(day)}, ${year}`; }; export const isValidDateString = (value: string) => { diff --git a/packages/lib/utils/videoUpload.ts b/packages/lib/utils/videoUpload.ts index 36563ca74c..bae60fc30b 100644 --- a/packages/lib/utils/videoUpload.ts +++ b/packages/lib/utils/videoUpload.ts @@ -21,7 +21,7 @@ export const checkForYoutubeUrl = (url: string): boolean => { } }; -export const checkForVimeoUrl = (url: string): boolean => { +const checkForVimeoUrl = (url: string): boolean => { try { const vimeoUrl = new URL(url); @@ -37,7 +37,7 @@ export const checkForVimeoUrl = (url: string): boolean => { } }; -export const checkForLoomUrl = (url: string): boolean => { +const checkForLoomUrl = (url: string): boolean => { try { const loomUrl = new URL(url); diff --git a/packages/react-native/src/components/survey-web-view.tsx b/packages/react-native/src/components/survey-web-view.tsx index 98144ce1c0..a7e81bdc01 100644 --- a/packages/react-native/src/components/survey-web-view.tsx +++ b/packages/react-native/src/components/survey-web-view.tsx @@ -1,13 +1,13 @@ /* eslint-disable no-console -- debugging*/ +import React, { type JSX, useEffect, useRef, useState } from "react"; +import { Modal } from "react-native"; +import { WebView, type WebViewMessageEvent } from "react-native-webview"; import { RNConfig } from "@/lib/common/config"; import { Logger } from "@/lib/common/logger"; import { filterSurveys, getLanguageCode, getStyling } from "@/lib/common/utils"; import { SurveyStore } from "@/lib/survey/store"; import { type TEnvironmentStateSurvey, type TUserState, ZJsRNWebViewOnMessageData } from "@/types/config"; import type { SurveyContainerProps } from "@/types/survey"; -import React, { type JSX, useEffect, useRef, useState } from "react"; -import { Modal } from "react-native"; -import { WebView, type WebViewMessageEvent } from "react-native-webview"; const appConfig = RNConfig.getInstance(); const logger = Logger.getInstance(); diff --git a/packages/surveys/src/components/general/ending-card.tsx b/packages/surveys/src/components/general/ending-card.tsx index a6e4cc01aa..fe12494586 100644 --- a/packages/surveys/src/components/general/ending-card.tsx +++ b/packages/surveys/src/components/general/ending-card.tsx @@ -4,9 +4,9 @@ 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 { getLocalizedValue } from "@/lib/i18n"; import { replaceRecallInfo } from "@/lib/recall"; import { useEffect } from "preact/hooks"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; 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"; diff --git a/packages/surveys/src/components/general/file-input.tsx b/packages/surveys/src/components/general/file-input.tsx index d02c3f7985..fb6ef9aee6 100644 --- a/packages/surveys/src/components/general/file-input.tsx +++ b/packages/surveys/src/components/general/file-input.tsx @@ -1,8 +1,8 @@ +import { getOriginalFileNameFromUrl } from "@/lib/storage"; +import { isFulfilled, isRejected } from "@/lib/utils"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useMemo, useState } from "preact/hooks"; import { type JSXInternal } from "preact/src/jsx"; -import { getOriginalFileNameFromUrl } from "@formbricks/lib/storage/utils"; -import { isFulfilled, isRejected } from "@formbricks/lib/utils/promises"; import { type TAllowedFileExtension } from "@formbricks/types/common"; import { type TJsFileUploadParams } from "@formbricks/types/js"; import { type TUploadFileConfig } from "@formbricks/types/storage"; diff --git a/packages/surveys/src/components/general/language-switch.tsx b/packages/surveys/src/components/general/language-switch.tsx index 96f9839ed6..7b23cdb7ce 100644 --- a/packages/surveys/src/components/general/language-switch.tsx +++ b/packages/surveys/src/components/general/language-switch.tsx @@ -1,5 +1,5 @@ import { GlobeIcon } from "@/components/general/globe-icon"; -import { useClickOutside } from "@/lib/utils"; +import { useClickOutside } from "@/lib/use-click-outside-hook"; import { useRef, useState } from "preact/hooks"; import { getLanguageLabel } from "@formbricks/lib/i18n/utils"; import { type TSurveyLanguage } from "@formbricks/types/surveys/types"; diff --git a/packages/surveys/src/components/general/question-conditional.tsx b/packages/surveys/src/components/general/question-conditional.tsx index b67be6f3c7..2e56ceedf3 100644 --- a/packages/surveys/src/components/general/question-conditional.tsx +++ b/packages/surveys/src/components/general/question-conditional.tsx @@ -13,7 +13,7 @@ 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 { getLocalizedValue } from "@/lib/i18n"; 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"; diff --git a/packages/surveys/src/components/general/question-media.tsx b/packages/surveys/src/components/general/question-media.tsx index 5ec21a9862..91bd97f76c 100644 --- a/packages/surveys/src/components/general/question-media.tsx +++ b/packages/surveys/src/components/general/question-media.tsx @@ -1,10 +1,5 @@ +import { checkForLoomUrl, checkForVimeoUrl, checkForYoutubeUrl, convertToEmbedUrl } from "@/lib/video-upload"; import { useState } from "preact/hooks"; -import { - checkForLoomUrl, - checkForVimeoUrl, - checkForYoutubeUrl, - convertToEmbedUrl, -} from "@formbricks/lib/utils/videoUpload"; //Function to add extra params to videoUrls in order to reduce video controls const getVideoUrlWithParams = (videoUrl: string): string => { diff --git a/packages/surveys/src/components/general/response-error-component.tsx b/packages/surveys/src/components/general/response-error-component.tsx index b29f4b8c38..b9a2370bcd 100644 --- a/packages/surveys/src/components/general/response-error-component.tsx +++ b/packages/surveys/src/components/general/response-error-component.tsx @@ -1,5 +1,5 @@ import { SubmitButton } from "@/components/buttons/submit-button"; -import { processResponseData } from "@formbricks/lib/responses"; +import { processResponseData } from "@/lib/response"; import { type TResponseData } from "@formbricks/types/responses"; import { type TSurveyQuestion } from "@formbricks/types/surveys/types"; diff --git a/packages/surveys/src/components/general/survey.tsx b/packages/surveys/src/components/general/survey.tsx index d2748bf238..0ac7c4796b 100644 --- a/packages/surveys/src/components/general/survey.tsx +++ b/packages/surveys/src/components/general/survey.tsx @@ -9,13 +9,13 @@ import { WelcomeCard } from "@/components/general/welcome-card"; import { AutoCloseWrapper } from "@/components/wrappers/auto-close-wrapper"; import { StackedCardsContainer } from "@/components/wrappers/stacked-cards-container"; import { ApiClient } from "@/lib/api-client"; +import { evaluateLogic, performActions } from "@/lib/logic"; import { parseRecallInformation } from "@/lib/recall"; import { ResponseQueue } from "@/lib/response-queue"; import { SurveyState } from "@/lib/survey-state"; import { cn, getDefaultLanguageCode } from "@/lib/utils"; import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import { type JSX, useCallback } from "react"; -import { evaluateLogic, performActions } from "@formbricks/lib/surveyLogic/utils"; import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys"; import { type TJsEnvironmentStateSurvey, TJsFileUploadParams } from "@formbricks/types/js"; import type { diff --git a/packages/surveys/src/components/general/welcome-card.tsx b/packages/surveys/src/components/general/welcome-card.tsx index 72989c375b..4c8d4e3fa3 100644 --- a/packages/surveys/src/components/general/welcome-card.tsx +++ b/packages/surveys/src/components/general/welcome-card.tsx @@ -1,9 +1,9 @@ import { SubmitButton } from "@/components/buttons/submit-button"; import { ScrollableContainer } from "@/components/wrappers/scrollable-container"; +import { getLocalizedValue } from "@/lib/i18n"; import { replaceRecallInfo } from "@/lib/recall"; import { calculateElementIdx } from "@/lib/utils"; import { useEffect } from "preact/hooks"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; 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"; diff --git a/packages/surveys/src/components/questions/address-question.tsx b/packages/surveys/src/components/questions/address-question.tsx index 388e3f39f3..cca80aa04c 100644 --- a/packages/surveys/src/components/questions/address-question.tsx +++ b/packages/surveys/src/components/questions/address-question.tsx @@ -5,10 +5,10 @@ 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 { getLocalizedValue } from "@/lib/i18n"; 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 { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; import type { TSurveyAddressQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; diff --git a/packages/surveys/src/components/questions/cal-question.tsx b/packages/surveys/src/components/questions/cal-question.tsx index 4067e86649..675b246868 100644 --- a/packages/surveys/src/components/questions/cal-question.tsx +++ b/packages/surveys/src/components/questions/cal-question.tsx @@ -5,9 +5,9 @@ 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 { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { useCallback, useState } from "preact/hooks"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; import { type TSurveyCalQuestion, type TSurveyQuestionId } from "@formbricks/types/surveys/types"; diff --git a/packages/surveys/src/components/questions/consent-question.tsx b/packages/surveys/src/components/questions/consent-question.tsx index e5b1a5a822..9d03d4dda0 100644 --- a/packages/surveys/src/components/questions/consent-question.tsx +++ b/packages/surveys/src/components/questions/consent-question.tsx @@ -4,9 +4,9 @@ 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 { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { useCallback, useState } from "preact/hooks"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; import type { TSurveyConsentQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; diff --git a/packages/surveys/src/components/questions/contact-info-question.tsx b/packages/surveys/src/components/questions/contact-info-question.tsx index f33f26a2e0..edfad42a99 100644 --- a/packages/surveys/src/components/questions/contact-info-question.tsx +++ b/packages/surveys/src/components/questions/contact-info-question.tsx @@ -5,9 +5,9 @@ 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 { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { useCallback, useMemo, useRef, useState } from "preact/hooks"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; import type { TSurveyContactInfoQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; diff --git a/packages/surveys/src/components/questions/cta-question.tsx b/packages/surveys/src/components/questions/cta-question.tsx index 5172688895..2d4a669d43 100644 --- a/packages/surveys/src/components/questions/cta-question.tsx +++ b/packages/surveys/src/components/questions/cta-question.tsx @@ -4,9 +4,9 @@ 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 { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { useState } from "react"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; import type { TSurveyCTAQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; diff --git a/packages/surveys/src/components/questions/date-question.tsx b/packages/surveys/src/components/questions/date-question.tsx index 37aa42f7bb..0a8b58559d 100644 --- a/packages/surveys/src/components/questions/date-question.tsx +++ b/packages/surveys/src/components/questions/date-question.tsx @@ -4,13 +4,13 @@ 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 { getMonthName, getOrdinalDate } from "@/lib/date-time"; +import { getLocalizedValue } from "@/lib/i18n"; 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 { DatePickerProps } from "react-date-picker"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; -import { getMonthName, getOrdinalDate } from "@formbricks/lib/utils/datetime"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; import type { TSurveyDateQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; import "../../styles/date-picker.css"; diff --git a/packages/surveys/src/components/questions/file-upload-question.tsx b/packages/surveys/src/components/questions/file-upload-question.tsx index c12d7842d6..e757fb7172 100644 --- a/packages/surveys/src/components/questions/file-upload-question.tsx +++ b/packages/surveys/src/components/questions/file-upload-question.tsx @@ -2,9 +2,9 @@ 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 { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { useState } from "preact/hooks"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { type TJsFileUploadParams } from "@formbricks/types/js"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; import { type TUploadFileConfig } from "@formbricks/types/storage"; diff --git a/packages/surveys/src/components/questions/matrix-question.tsx b/packages/surveys/src/components/questions/matrix-question.tsx index d6d9d6197e..98fc65a11d 100644 --- a/packages/surveys/src/components/questions/matrix-question.tsx +++ b/packages/surveys/src/components/questions/matrix-question.tsx @@ -4,11 +4,11 @@ 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 { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { getShuffledRowIndices } from "@/lib/utils"; import { type JSX } from "preact"; import { useCallback, useMemo, useState } from "preact/hooks"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; import type { TI18nString, TSurveyMatrixQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; diff --git a/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx b/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx index 337ac169bc..dc46955feb 100644 --- a/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx +++ b/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx @@ -4,10 +4,10 @@ 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 { getLocalizedValue } from "@/lib/i18n"; 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 { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; import type { TSurveyMultipleChoiceQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; diff --git a/packages/surveys/src/components/questions/multiple-choice-single-question.tsx b/packages/surveys/src/components/questions/multiple-choice-single-question.tsx index 56b213f062..bac793834e 100644 --- a/packages/surveys/src/components/questions/multiple-choice-single-question.tsx +++ b/packages/surveys/src/components/questions/multiple-choice-single-question.tsx @@ -4,10 +4,10 @@ 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 { getLocalizedValue } from "@/lib/i18n"; 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 { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; import type { TSurveyMultipleChoiceQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; diff --git a/packages/surveys/src/components/questions/nps-question.tsx b/packages/surveys/src/components/questions/nps-question.tsx index 42a152084d..95fb806e1a 100644 --- a/packages/surveys/src/components/questions/nps-question.tsx +++ b/packages/surveys/src/components/questions/nps-question.tsx @@ -4,10 +4,10 @@ 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 { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { cn } from "@/lib/utils"; import { useState } from "preact/hooks"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; import type { TSurveyNPSQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; diff --git a/packages/surveys/src/components/questions/open-text-question.tsx b/packages/surveys/src/components/questions/open-text-question.tsx index ac94413592..20ccbea7ab 100644 --- a/packages/surveys/src/components/questions/open-text-question.tsx +++ b/packages/surveys/src/components/questions/open-text-question.tsx @@ -4,10 +4,10 @@ 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 { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { type RefObject } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; import type { TSurveyOpenTextQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; diff --git a/packages/surveys/src/components/questions/picture-selection-question.tsx b/packages/surveys/src/components/questions/picture-selection-question.tsx index f6dda3cd70..363d183274 100644 --- a/packages/surveys/src/components/questions/picture-selection-question.tsx +++ b/packages/surveys/src/components/questions/picture-selection-question.tsx @@ -4,11 +4,11 @@ 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 { getLocalizedValue } from "@/lib/i18n"; +import { getOriginalFileNameFromUrl } from "@/lib/storage"; 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 { getOriginalFileNameFromUrl } from "@formbricks/lib/storage/utils"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; import type { TSurveyPictureSelectionQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; diff --git a/packages/surveys/src/components/questions/ranking-question.tsx b/packages/surveys/src/components/questions/ranking-question.tsx index 301a59c983..8bf68b239c 100644 --- a/packages/surveys/src/components/questions/ranking-question.tsx +++ b/packages/surveys/src/components/questions/ranking-question.tsx @@ -4,11 +4,11 @@ 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 { getLocalizedValue } from "@/lib/i18n"; 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 { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; import type { TSurveyQuestionChoice, diff --git a/packages/surveys/src/components/questions/rating-question.tsx b/packages/surveys/src/components/questions/rating-question.tsx index c93200dc40..d81825a227 100644 --- a/packages/surveys/src/components/questions/rating-question.tsx +++ b/packages/surveys/src/components/questions/rating-question.tsx @@ -3,11 +3,11 @@ 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 { getLocalizedValue } from "@/lib/i18n"; 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 { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; import type { TSurveyQuestionId, TSurveyRatingQuestion } from "@formbricks/types/surveys/types"; import { diff --git a/packages/surveys/src/components/wrappers/survey-container.tsx b/packages/surveys/src/components/wrappers/survey-container.tsx index dd23d8f4d9..6096394cb6 100644 --- a/packages/surveys/src/components/wrappers/survey-container.tsx +++ b/packages/surveys/src/components/wrappers/survey-container.tsx @@ -1,5 +1,5 @@ +import { cn } from "@/lib/utils"; import { useEffect, useRef, useState } from "preact/hooks"; -import { cn } from "@formbricks/lib/cn"; import { type TPlacement } from "@formbricks/types/common"; interface SurveyContainerProps { diff --git a/packages/surveys/src/lib/color.ts b/packages/surveys/src/lib/color.ts new file mode 100644 index 0000000000..5f8ba6d343 --- /dev/null +++ b/packages/surveys/src/lib/color.ts @@ -0,0 +1,53 @@ +const hexToRGBA = (hex: string | undefined, opacity: number): string | undefined => { + // return undefined if hex is undefined, this is important for adding the default values to the CSS variables + // TODO: find a better way to handle this + if (!hex || hex === "") return undefined; + + // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") + let shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; + hex = hex.replace(shorthandRegex, (_, r, g, b) => r + r + g + g + b + b); + + let result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (!result) return ""; + + let r = parseInt(result[1], 16); + let g = parseInt(result[2], 16); + let b = parseInt(result[3], 16); + + return `rgba(${r}, ${g}, ${b}, ${opacity})`; +}; + +export const mixColor = (hexColor: string, mixWithHex: string, weight: number): string => { + // Convert both colors to RGBA format + const color1 = hexToRGBA(hexColor, 1) || ""; + const color2 = hexToRGBA(mixWithHex, 1) || ""; + + // Extract RGBA values + const [r1, g1, b1] = color1.match(/\d+/g)?.map(Number) || [0, 0, 0]; + const [r2, g2, b2] = color2.match(/\d+/g)?.map(Number) || [0, 0, 0]; + + // Mix the colors + const r = Math.round(r1 * (1 - weight) + r2 * weight); + const g = Math.round(g1 * (1 - weight) + g2 * weight); + const b = Math.round(b1 * (1 - weight) + b2 * weight); + + return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; +}; + +export const isLight = (color: string) => { + let r: number | undefined, g: number | undefined, b: number | undefined; + + if (color.length === 4) { + r = parseInt(color[1] + color[1], 16); + g = parseInt(color[2] + color[2], 16); + b = parseInt(color[3] + color[3], 16); + } else if (color.length === 7) { + r = parseInt(color[1] + color[2], 16); + g = parseInt(color[3] + color[4], 16); + b = parseInt(color[5] + color[6], 16); + } + if (r === undefined || g === undefined || b === undefined) { + throw new Error("Invalid color"); + } + return r * 0.299 + g * 0.587 + b * 0.114 > 128; +}; diff --git a/packages/surveys/src/lib/date-time.ts b/packages/surveys/src/lib/date-time.ts new file mode 100644 index 0000000000..457ccf3c1b --- /dev/null +++ b/packages/surveys/src/lib/date-time.ts @@ -0,0 +1,48 @@ +// Helper function to get the month name +export const getMonthName = (monthIndex: number, locale: string = "en-US") => { + if (monthIndex < 0 || monthIndex > 11) { + throw new Error("Month index must be between 0 and 11"); + } + return new Intl.DateTimeFormat(locale, { month: "long" }).format(new Date(2000, monthIndex, 1)); +}; + +// Helper function to format the date with an ordinal suffix +export const getOrdinalDate = (date: number) => { + const j = date % 10, + k = date % 100; + if (j === 1 && k !== 11) { + return date + "st"; + } + if (j === 2 && k !== 12) { + return date + "nd"; + } + if (j === 3 && k !== 13) { + return date + "rd"; + } + return date + "th"; +}; + +export const isValidDateString = (value: string) => { + const regex = /^(?:\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4})$/; + + if (!regex.test(value)) { + return false; + } + + const date = new Date(value); + return !isNaN(date.getTime()); +}; + +const getOrdinalSuffix = (day: number): string => { + const suffixes = ["th", "st", "nd", "rd"]; + const relevantDigits = day < 30 ? day % 20 : day % 30; + return suffixes[relevantDigits <= 3 ? relevantDigits : 0]; +}; + +export const formatDateWithOrdinal = (date: Date, locale: string = "en-US"): string => { + const dayOfWeek = new Intl.DateTimeFormat(locale, { weekday: "long" }).format(date); + const day = date.getDate(); + const month = new Intl.DateTimeFormat(locale, { month: "long" }).format(date); + const year = date.getFullYear(); + return `${dayOfWeek}, ${month} ${day}${getOrdinalSuffix(day)}, ${year}`; +}; diff --git a/packages/surveys/src/lib/i18n.ts b/packages/surveys/src/lib/i18n.ts new file mode 100644 index 0000000000..371b50e3ec --- /dev/null +++ b/packages/surveys/src/lib/i18n.ts @@ -0,0 +1,19 @@ +import { TI18nString } from "@formbricks/types/surveys/types"; + +// Type guard to check if an object is an I18nString +const isI18nObject = (obj: any): obj is TI18nString => { + return typeof obj === "object" && obj !== null && Object.keys(obj).includes("default"); +}; + +export const getLocalizedValue = (value: TI18nString | undefined, languageId: string): string => { + if (!value) { + return ""; + } + if (isI18nObject(value)) { + if (value[languageId]) { + return value[languageId]; + } + return value.default; + } + return ""; +}; diff --git a/packages/surveys/src/lib/logic.ts b/packages/surveys/src/lib/logic.ts new file mode 100644 index 0000000000..54c84da65d --- /dev/null +++ b/packages/surveys/src/lib/logic.ts @@ -0,0 +1,473 @@ +import { getLocalizedValue } from "@/lib/i18n"; +import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; +import { TResponseData, TResponseVariables } from "@formbricks/types/responses"; +import { + TActionCalculate, + TConditionGroup, + TSingleCondition, + TSurveyLogicAction, + TSurveyQuestion, + TSurveyQuestionTypeEnum, + TSurveyVariable, +} from "@formbricks/types/surveys/types"; + +const getVariableValue = ( + variables: TSurveyVariable[], + variableId: string, + variablesData: TResponseVariables +) => { + const variable = variables.find((v) => v.id === variableId); + if (!variable) return undefined; + const variableValue = variablesData[variableId]; + return variable.type === "number" ? Number(variableValue) || 0 : variableValue || ""; +}; + +type TCondition = TSingleCondition | TConditionGroup; + +export const isConditionGroup = (condition: TCondition): condition is TConditionGroup => { + return (condition as TConditionGroup).connector !== undefined; +}; + +export const evaluateLogic = ( + localSurvey: TJsEnvironmentStateSurvey, + data: TResponseData, + variablesData: TResponseVariables, + conditions: TConditionGroup, + selectedLanguage: string +): boolean => { + const evaluateConditionGroup = (group: TConditionGroup): boolean => { + const results = group.conditions.map((condition) => { + if (isConditionGroup(condition)) { + return evaluateConditionGroup(condition); + } else { + return evaluateSingleCondition(localSurvey, data, variablesData, condition, selectedLanguage); + } + }); + + return group.connector === "or" ? results.some((r) => r) : results.every((r) => r); + }; + + return evaluateConditionGroup(conditions); +}; + +export const performActions = ( + survey: TJsEnvironmentStateSurvey, + actions: TSurveyLogicAction[], + data: TResponseData, + calculationResults: TResponseVariables +): { + jumpTarget: string | undefined; + requiredQuestionIds: string[]; + calculations: TResponseVariables; +} => { + let jumpTarget: string | undefined; + const requiredQuestionIds: string[] = []; + const calculations: TResponseVariables = { ...calculationResults }; + + actions.forEach((action) => { + switch (action.objective) { + case "calculate": + const result = performCalculation(survey, action, data, calculations); + if (result !== undefined) calculations[action.variableId] = result; + break; + case "requireAnswer": + requiredQuestionIds.push(action.target); + break; + case "jumpToQuestion": + if (!jumpTarget) { + jumpTarget = action.target; + } + break; + } + }); + + return { jumpTarget, requiredQuestionIds, calculations }; +}; + +const getLeftOperandValue = ( + localSurvey: TJsEnvironmentStateSurvey, + data: TResponseData, + variablesData: TResponseVariables, + leftOperand: TSingleCondition["leftOperand"], + selectedLanguage: string +) => { + switch (leftOperand.type) { + case "question": + const currentQuestion = localSurvey.questions.find((q) => q.id === leftOperand.value); + if (!currentQuestion) return undefined; + + const responseValue = data[leftOperand.value]; + + if (currentQuestion.type === "openText" && currentQuestion.inputType === "number") { + return Number(responseValue) || undefined; + } + + if (currentQuestion.type === "multipleChoiceSingle" || currentQuestion.type === "multipleChoiceMulti") { + const isOthersEnabled = currentQuestion.choices.at(-1)?.id === "other"; + + if (typeof responseValue === "string") { + const choice = currentQuestion.choices.find((choice) => { + return getLocalizedValue(choice.label, selectedLanguage) === responseValue; + }); + + if (!choice) { + if (isOthersEnabled) { + return "other"; + } + + return undefined; + } + + return choice.id; + } else if (Array.isArray(responseValue)) { + let choices: string[] = []; + responseValue.forEach((value) => { + const foundChoice = currentQuestion.choices.find((choice) => { + return getLocalizedValue(choice.label, selectedLanguage) === value; + }); + + if (foundChoice) { + choices.push(foundChoice.id); + } else if (isOthersEnabled) { + choices.push("other"); + } + }); + if (choices) { + return Array.from(new Set(choices)); + } + } + } + + return data[leftOperand.value]; + case "variable": + const variables = localSurvey.variables || []; + return getVariableValue(variables, leftOperand.value, variablesData); + case "hiddenField": + return data[leftOperand.value]; + default: + return undefined; + } +}; + +const getRightOperandValue = ( + localSurvey: TJsEnvironmentStateSurvey, + data: TResponseData, + variablesData: TResponseVariables, + rightOperand: TSingleCondition["rightOperand"] +) => { + if (!rightOperand) return undefined; + + switch (rightOperand.type) { + case "question": + return data[rightOperand.value]; + case "variable": + const variables = localSurvey.variables || []; + return getVariableValue(variables, rightOperand.value, variablesData); + case "hiddenField": + return data[rightOperand.value]; + case "static": + return rightOperand.value; + default: + return undefined; + } +}; + +const evaluateSingleCondition = ( + localSurvey: TJsEnvironmentStateSurvey, + data: TResponseData, + variablesData: TResponseVariables, + condition: TSingleCondition, + selectedLanguage: string +): boolean => { + try { + let leftValue = getLeftOperandValue( + localSurvey, + data, + variablesData, + condition.leftOperand, + selectedLanguage + ); + let rightValue = condition.rightOperand + ? getRightOperandValue(localSurvey, data, variablesData, condition.rightOperand) + : undefined; + + let leftField: TSurveyQuestion | TSurveyVariable | string; + + if (condition.leftOperand?.type === "question") { + leftField = localSurvey.questions.find((q) => q.id === condition.leftOperand?.value) as TSurveyQuestion; + } else if (condition.leftOperand?.type === "variable") { + leftField = localSurvey.variables.find((v) => v.id === condition.leftOperand?.value) as TSurveyVariable; + } else if (condition.leftOperand?.type === "hiddenField") { + leftField = condition.leftOperand.value as string; + } else { + leftField = ""; + } + + let rightField: TSurveyQuestion | TSurveyVariable | string; + + if (condition.rightOperand?.type === "question") { + rightField = localSurvey.questions.find( + (q) => q.id === condition.rightOperand?.value + ) as TSurveyQuestion; + } else if (condition.rightOperand?.type === "variable") { + rightField = localSurvey.variables.find( + (v) => v.id === condition.rightOperand?.value + ) as TSurveyVariable; + } else if (condition.rightOperand?.type === "hiddenField") { + rightField = condition.rightOperand.value as string; + } else { + rightField = ""; + } + + if ( + condition.leftOperand.type === "variable" && + (leftField as TSurveyVariable).type === "number" && + condition.rightOperand?.type === "hiddenField" + ) { + rightValue = Number(rightValue as string); + } + + switch (condition.operator) { + case "equals": + if (condition.leftOperand.type === "question") { + if ( + (leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date && + typeof leftValue === "string" && + typeof rightValue === "string" + ) { + // when left value is of date question and right value is string + return new Date(leftValue).getTime() === new Date(rightValue).getTime(); + } + } + + // when left value is of openText, hiddenField, variable and right value is of multichoice + if (condition.rightOperand?.type === "question") { + if ((rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) { + if (Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.length === 1) { + return rightValue.includes(leftValue as string); + } else return false; + } else if ( + (rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date && + typeof leftValue === "string" && + typeof rightValue === "string" + ) { + return new Date(leftValue).getTime() === new Date(rightValue).getTime(); + } + } + + return ( + (Array.isArray(leftValue) && + leftValue.length === 1 && + typeof rightValue === "string" && + leftValue.includes(rightValue)) || + leftValue === rightValue + ); + case "doesNotEqual": + // when left value is of picture selection question and right value is its option + if ( + condition.leftOperand.type === "question" && + (leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.PictureSelection && + Array.isArray(leftValue) && + leftValue.length > 0 && + typeof rightValue === "string" + ) { + return !leftValue.includes(rightValue); + } + + // when left value is of date question and right value is string + if ( + condition.leftOperand.type === "question" && + (leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date && + typeof leftValue === "string" && + typeof rightValue === "string" + ) { + return new Date(leftValue).getTime() !== new Date(rightValue).getTime(); + } + + // when left value is of openText, hiddenField, variable and right value is of multichoice + if (condition.rightOperand?.type === "question") { + if ((rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) { + if (Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.length === 1) { + return !rightValue.includes(leftValue as string); + } else return false; + } else if ( + (rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date && + typeof leftValue === "string" && + typeof rightValue === "string" + ) { + return new Date(leftValue).getTime() !== new Date(rightValue).getTime(); + } + } + + return ( + (Array.isArray(leftValue) && + leftValue.length === 1 && + typeof rightValue === "string" && + !leftValue.includes(rightValue)) || + leftValue !== rightValue + ); + case "contains": + return String(leftValue).includes(String(rightValue)); + case "doesNotContain": + return !String(leftValue).includes(String(rightValue)); + case "startsWith": + return String(leftValue).startsWith(String(rightValue)); + case "doesNotStartWith": + return !String(leftValue).startsWith(String(rightValue)); + case "endsWith": + return String(leftValue).endsWith(String(rightValue)); + case "doesNotEndWith": + return !String(leftValue).endsWith(String(rightValue)); + case "isSubmitted": + if (typeof leftValue === "string") { + if ( + condition.leftOperand.type === "question" && + (leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.FileUpload && + leftValue + ) { + return leftValue !== "skipped"; + } + return leftValue !== "" && leftValue !== null; + } else if (Array.isArray(leftValue)) { + return leftValue.length > 0; + } else if (typeof leftValue === "number") { + return leftValue !== null; + } + return false; + case "isSkipped": + return ( + (Array.isArray(leftValue) && leftValue.length === 0) || + leftValue === "" || + leftValue === null || + leftValue === undefined || + (typeof leftValue === "object" && Object.entries(leftValue).length === 0) + ); + case "isGreaterThan": + return Number(leftValue) > Number(rightValue); + case "isLessThan": + return Number(leftValue) < Number(rightValue); + case "isGreaterThanOrEqual": + return Number(leftValue) >= Number(rightValue); + case "isLessThanOrEqual": + return Number(leftValue) <= Number(rightValue); + case "equalsOneOf": + return Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.includes(leftValue); + case "includesAllOf": + return ( + Array.isArray(leftValue) && + Array.isArray(rightValue) && + rightValue.every((v) => leftValue.includes(v)) + ); + case "includesOneOf": + return ( + Array.isArray(leftValue) && + Array.isArray(rightValue) && + rightValue.some((v) => leftValue.includes(v)) + ); + case "doesNotIncludeAllOf": + return ( + Array.isArray(leftValue) && + Array.isArray(rightValue) && + rightValue.every((v) => !leftValue.includes(v)) + ); + case "doesNotIncludeOneOf": + return ( + Array.isArray(leftValue) && + Array.isArray(rightValue) && + rightValue.some((v) => !leftValue.includes(v)) + ); + case "isAccepted": + return leftValue === "accepted"; + case "isClicked": + return leftValue === "clicked"; + case "isAfter": + return new Date(String(leftValue)) > new Date(String(rightValue)); + case "isBefore": + return new Date(String(leftValue)) < new Date(String(rightValue)); + case "isBooked": + return leftValue === "booked" || !!(leftValue && leftValue !== ""); + case "isPartiallySubmitted": + if (typeof leftValue === "object") { + return Object.values(leftValue).includes(""); + } else return false; + case "isCompletelySubmitted": + if (typeof leftValue === "object") { + const values = Object.values(leftValue); + return values.length > 0 && !values.includes(""); + } else return false; + default: + return false; + } + } catch (e) { + return false; + } +}; + +const performCalculation = ( + survey: TJsEnvironmentStateSurvey, + action: TActionCalculate, + data: TResponseData, + calculations: Record +): number | string | undefined => { + const variables = survey.variables || []; + const variable = variables.find((v) => v.id === action.variableId); + + if (!variable) return undefined; + + let currentValue = calculations[action.variableId]; + if (currentValue === undefined) { + currentValue = variable.type === "number" ? 0 : ""; + } + let operandValue: string | number | undefined; + + // Determine the operand value based on the action.value type + switch (action.value.type) { + case "static": + operandValue = action.value.value; + break; + case "variable": + const value = calculations[action.value.value]; + if (typeof value === "number" || typeof value === "string") { + operandValue = value; + } + break; + case "question": + case "hiddenField": + const val = data[action.value.value]; + if (typeof val === "number" || typeof val === "string") { + if (variable.type === "number" && !isNaN(Number(val))) { + operandValue = Number(val); + } + operandValue = val; + } + break; + } + + if (operandValue === undefined || operandValue === null) return undefined; + + let result: number | string; + + switch (action.operator) { + case "add": + result = Number(currentValue) + Number(operandValue); + break; + case "subtract": + result = Number(currentValue) - Number(operandValue); + break; + case "multiply": + result = Number(currentValue) * Number(operandValue); + break; + case "divide": + if (Number(operandValue) === 0) return undefined; + result = Number(currentValue) / Number(operandValue); + break; + case "assign": + result = operandValue; + break; + case "concat": + result = String(currentValue) + String(operandValue); + break; + } + + return result; +}; diff --git a/packages/surveys/src/lib/recall.ts b/packages/surveys/src/lib/recall.ts index 89aa16d4e2..e2e78f2081 100644 --- a/packages/surveys/src/lib/recall.ts +++ b/packages/surveys/src/lib/recall.ts @@ -1,10 +1,38 @@ -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 { formatDateWithOrdinal, isValidDateString } from "@/lib/date-time"; +import { getLocalizedValue } from "@/lib/i18n"; import { type TResponseData, type TResponseVariables } from "@formbricks/types/responses"; import { type TSurveyQuestion } from "@formbricks/types/surveys/types"; +// Extracts the ID of recall question from a string containing the "recall" pattern. +const extractId = (text: string): string | null => { + const pattern = /#recall:([A-Za-z0-9_-]+)/; + const match = text.match(pattern); + if (match && match[1]) { + return match[1]; + } else { + return null; + } +}; + +// Extracts the fallback value from a string containing the "fallback" pattern. +const extractFallbackValue = (text: string): string => { + const pattern = /fallback:(\S*)#/; + const match = text.match(pattern); + if (match && match[1]) { + return match[1]; + } else { + return ""; + } +}; + +// Extracts the complete recall information (ID and fallback) from a headline string. +const extractRecallInfo = (headline: string, id?: string): string | null => { + const idPattern = id ? id : "[A-Za-z0-9_-]+"; + const pattern = new RegExp(`#recall:(${idPattern})\\/fallback:(\\S*)#`); + const match = headline.match(pattern); + return match ? match[0] : null; +}; + export const replaceRecallInfo = ( text: string, responseData: TResponseData, @@ -54,7 +82,7 @@ export const parseRecallInformation = ( responseData: TResponseData, variables: TResponseVariables ) => { - const modifiedQuestion = structuredClone(question); + const modifiedQuestion = JSON.parse(JSON.stringify(question)); if (question.headline[languageCode].includes("recall:")) { modifiedQuestion.headline[languageCode] = replaceRecallInfo( getLocalizedValue(modifiedQuestion.headline, languageCode), diff --git a/packages/surveys/src/lib/response.ts b/packages/surveys/src/lib/response.ts new file mode 100644 index 0000000000..c61fc73d8f --- /dev/null +++ b/packages/surveys/src/lib/response.ts @@ -0,0 +1,28 @@ +export const processResponseData = ( + responseData: string | number | string[] | Record +): string => { + switch (typeof responseData) { + case "string": + return responseData; + + case "number": + return responseData.toString(); + + case "object": + if (Array.isArray(responseData)) { + responseData = responseData + .filter((item) => item !== null && item !== undefined && item !== "") + .join(", "); + return responseData; + } else { + const formattedString = Object.entries(responseData) + .filter(([_, value]) => value !== "") + .map(([key, value]) => `${key}: ${value}`) + .join("\n"); + return formattedString; + } + + default: + return ""; + } +}; diff --git a/packages/surveys/src/lib/storage.ts b/packages/surveys/src/lib/storage.ts new file mode 100644 index 0000000000..5b0a902977 --- /dev/null +++ b/packages/surveys/src/lib/storage.ts @@ -0,0 +1,22 @@ +export const getOriginalFileNameFromUrl = (fileURL: string): string => { + try { + const fileNameFromURL = fileURL.startsWith("/storage/") + ? fileURL.split("/").pop() + : new URL(fileURL).pathname.split("/").pop(); + + const fileExt = fileNameFromURL?.split(".").pop() ?? ""; + const originalFileName = fileNameFromURL?.split("--fid--")[0] ?? ""; + const fileId = fileNameFromURL?.split("--fid--")[1] ?? ""; + + if (!fileId) { + const fileName = originalFileName ? decodeURIComponent(originalFileName || "") : ""; + return fileName; + } + + const fileName = originalFileName ? decodeURIComponent(`${originalFileName}.${fileExt}` || "") : ""; + return fileName; + } catch (error) { + console.error(`Error parsing file URL: ${error}`); + return ""; + } +}; diff --git a/packages/surveys/src/lib/styles.ts b/packages/surveys/src/lib/styles.ts index c18d067e84..67eca2599d 100644 --- a/packages/surveys/src/lib/styles.ts +++ b/packages/surveys/src/lib/styles.ts @@ -1,8 +1,8 @@ +import { isLight, mixColor } from "@/lib/color"; import global from "@/styles/global.css?inline"; 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 { 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"; diff --git a/packages/surveys/src/lib/use-click-outside-hook.ts b/packages/surveys/src/lib/use-click-outside-hook.ts new file mode 100644 index 0000000000..d79af49a90 --- /dev/null +++ b/packages/surveys/src/lib/use-click-outside-hook.ts @@ -0,0 +1,36 @@ +import { MutableRef, useEffect } from "preact/hooks"; + +// Improved version of https://usehooks.com/useOnClickOutside/ +export const useClickOutside = ( + ref: MutableRef, + handler: (event: MouseEvent | TouchEvent) => void +): void => { + useEffect(() => { + let startedInside = false; + let startedWhenMounted = false; + + const listener = (event: MouseEvent | TouchEvent) => { + // Do nothing if `mousedown` or `touchstart` started inside ref element + if (startedInside || !startedWhenMounted) return; + // Do nothing if clicking ref's element or descendent elements + if (!ref.current || ref.current.contains(event.target as Node)) return; + + handler(event); + }; + + const validateEventStart = (event: MouseEvent | TouchEvent) => { + startedWhenMounted = ref.current !== null; + startedInside = ref.current !== null && ref.current.contains(event.target as Node); + }; + + document.addEventListener("mousedown", validateEventStart); + document.addEventListener("touchstart", validateEventStart); + document.addEventListener("click", listener); + + return () => { + document.removeEventListener("mousedown", validateEventStart); + document.removeEventListener("touchstart", validateEventStart); + document.removeEventListener("click", listener); + }; + }, [ref, handler]); +}; diff --git a/packages/surveys/src/lib/utils.ts b/packages/surveys/src/lib/utils.ts index 128583fe02..9167ca58fe 100644 --- a/packages/surveys/src/lib/utils.ts +++ b/packages/surveys/src/lib/utils.ts @@ -1,5 +1,4 @@ import { ApiResponse, ApiSuccessResponse } from "@/types/api"; -import { MutableRef, useEffect } from "preact/hooks"; import { type Result, err, ok, wrapThrowsAsync } from "@formbricks/types/error-handlers"; import { type ApiErrorResponse } from "@formbricks/types/errors"; import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js"; @@ -106,39 +105,12 @@ const getPossibleNextQuestions = (question: TSurveyQuestion): string[] => { return possibleDestinations; }; -// Improved version of https://usehooks.com/useOnClickOutside/ -export const useClickOutside = ( - ref: MutableRef, - handler: (event: MouseEvent | TouchEvent) => void -): void => { - useEffect(() => { - let startedInside = false; - let startedWhenMounted = false; +export const isFulfilled = (val: PromiseSettledResult): val is PromiseFulfilledResult => { + return val.status === "fulfilled"; +}; - const listener = (event: MouseEvent | TouchEvent) => { - // Do nothing if `mousedown` or `touchstart` started inside ref element - if (startedInside || !startedWhenMounted) return; - // Do nothing if clicking ref's element or descendent elements - if (!ref.current || ref.current.contains(event.target as Node)) return; - - handler(event); - }; - - const validateEventStart = (event: MouseEvent | TouchEvent) => { - startedWhenMounted = ref.current !== null; - startedInside = ref.current !== null && ref.current.contains(event.target as Node); - }; - - document.addEventListener("mousedown", validateEventStart); - document.addEventListener("touchstart", validateEventStart); - document.addEventListener("click", listener); - - return () => { - document.removeEventListener("mousedown", validateEventStart); - document.removeEventListener("touchstart", validateEventStart); - document.removeEventListener("click", listener); - }; - }, [ref, handler]); +export const isRejected = (val: PromiseSettledResult): val is PromiseRejectedResult => { + return val.status === "rejected"; }; export const makeRequest = async ( diff --git a/packages/surveys/src/lib/video-upload.ts b/packages/surveys/src/lib/video-upload.ts new file mode 100644 index 0000000000..36563ca74c --- /dev/null +++ b/packages/surveys/src/lib/video-upload.ts @@ -0,0 +1,127 @@ +export const checkForYoutubeUrl = (url: string): boolean => { + try { + const youtubeUrl = new URL(url); + + if (youtubeUrl.protocol !== "https:") return false; + + const youtubeDomains = [ + "www.youtube.com", + "www.youtu.be", + "www.youtube-nocookie.com", + "youtube.com", + "youtu.be", + "youtube-nocookie.com", + ]; + const hostname = youtubeUrl.hostname; + + return youtubeDomains.includes(hostname); + } catch (err) { + // invalid URL + return false; + } +}; + +export const checkForVimeoUrl = (url: string): boolean => { + try { + const vimeoUrl = new URL(url); + + if (vimeoUrl.protocol !== "https:") return false; + + const vimeoDomains = ["www.vimeo.com", "vimeo.com"]; + const hostname = vimeoUrl.hostname; + + return vimeoDomains.includes(hostname); + } catch (err) { + // invalid URL + return false; + } +}; + +export const checkForLoomUrl = (url: string): boolean => { + try { + const loomUrl = new URL(url); + + if (loomUrl.protocol !== "https:") return false; + + const loomDomains = ["www.loom.com", "loom.com"]; + const hostname = loomUrl.hostname; + + return loomDomains.includes(hostname); + } catch (err) { + // invalid URL + return false; + } +}; + +export const extractYoutubeId = (url: string): string | null => { + let id = ""; + + // Regular expressions for various YouTube URL formats + const regExpList = [ + /youtu\.be\/([a-zA-Z0-9_-]+)/, // youtu.be/ + /youtube\.com.*v=([a-zA-Z0-9_-]+)/, // youtube.com/watch?v= + /youtube\.com.*embed\/([a-zA-Z0-9_-]+)/, // youtube.com/embed/ + /youtube-nocookie\.com\/embed\/([a-zA-Z0-9_-]+)/, // youtube-nocookie.com/embed/ + ]; + + regExpList.some((regExp) => { + const match = url.match(regExp); + if (match && match[1]) { + id = match[1]; + return true; + } + return false; + }); + + return id || null; +}; + +const extractVimeoId = (url: string): string | null => { + const regExp = /vimeo\.com\/(\d+)/; + const match = url.match(regExp); + + if (match && match[1]) { + return match[1]; + } + return null; +}; + +const extractLoomId = (url: string): string | null => { + const regExp = /loom\.com\/share\/([a-zA-Z0-9]+)/; + const match = url.match(regExp); + + if (match && match[1]) { + return match[1]; + } + return null; +}; + +// Always convert a given URL into its embed form if supported. +export const convertToEmbedUrl = (url: string): string | undefined => { + // YouTube + if (checkForYoutubeUrl(url)) { + const videoId = extractYoutubeId(url); + if (videoId) { + return `https://www.youtube.com/embed/${videoId}`; + } + } + + // Vimeo + if (checkForVimeoUrl(url)) { + const videoId = extractVimeoId(url); + if (videoId) { + return `https://player.vimeo.com/video/${videoId}`; + } + } + + // Loom + if (checkForLoomUrl(url)) { + const videoId = extractLoomId(url); + if (videoId) { + return `https://www.loom.com/embed/${videoId}`; + } + } + + // If no supported platform found, return undefined + return undefined; +};