Compare commits

...

12 Commits

Author SHA1 Message Date
Dhruwang
2fdaf88572 feat: add v5 button component with Storybook integration
- Add new Button component in packages/surveys/src/components/v5/
- Implement button variants (default, destructive, outline, secondary, ghost, link)
- Add comprehensive Storybook stories with all variants and use cases
- Configure Storybook to support Preact components with React aliasing
- Add Preact-to-React aliases in Storybook vite config for compatibility
2025-11-24 14:05:42 +05:30
Dhruwang Jariwala
ed26427302 feat: add CSP nonce support for inline styles (#6796) (#6801) 2025-11-21 15:17:39 +00:00
Matti Nannt
554809742b fix: release pipeline boolean comparison for is_latest output (#6870) 2025-11-21 09:10:55 +00:00
Johannes
28adfb905c fix: Matrix filter (#6864)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-11-21 07:13:21 +00:00
Johannes
05c455ed62 fix: Link metadata (#6865) 2025-11-21 06:56:43 +00:00
Matti Nannt
f7687bc0ea fix: pin Prisma CLI to version 6 in Dockerfile (#6868) 2025-11-21 06:36:12 +00:00
Dhruwang Jariwala
af34391309 fix: filters not persisting in response page (#6862) 2025-11-20 15:14:44 +00:00
Dhruwang Jariwala
70978fbbdf fix: update preview when props change (#6860) 2025-11-20 13:26:55 +00:00
Matti Nannt
f6683d1165 fix: optimize survey list performance with client-side filtering (#6812)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-11-19 06:36:07 +00:00
Matti Nannt
13be7a8970 perf: Optimize link survey with server/client component architecture (#6764)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-11-19 06:31:41 +00:00
Dhruwang Jariwala
0472d5e8f0 fix: language switch tweak and docs feedback template (#6811) 2025-11-18 17:00:23 +00:00
Dhruwang Jariwala
00a61f7abe chore: response page optimization (#6843)
Co-authored-by: igor-srdoc <igor@srdoc.si>
2025-11-18 16:50:48 +00:00
57 changed files with 1521 additions and 411 deletions

View File

@@ -89,7 +89,7 @@ jobs:
- check-latest-release
with:
IS_PRERELEASE: ${{ github.event.release.prerelease }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
docker-build-cloud:
name: Build & push Formbricks Cloud to ECR
@@ -101,7 +101,7 @@ jobs:
with:
image_tag: ${{ needs.docker-build-community.outputs.VERSION }}
IS_PRERELEASE: ${{ github.event.release.prerelease }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
needs:
- check-latest-release
- docker-build-community
@@ -154,4 +154,4 @@ jobs:
release_tag: ${{ github.event.release.tag_name }}
commit_sha: ${{ github.sha }}
is_prerelease: ${{ github.event.release.prerelease }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}

View File

@@ -1,6 +1,7 @@
import type { StorybookConfig } from "@storybook/react-vite";
import { createRequire } from "module";
import { dirname, join } from "path";
import { mergeConfig } from "vite";
const require = createRequire(import.meta.url);
@@ -13,7 +14,11 @@ function getAbsolutePath(value: string): any {
}
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
stories: [
"../src/**/*.mdx",
"../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)",
"../../../packages/surveys/src/components/**/stories.@(js|jsx|mjs|ts|tsx)",
],
addons: [
getAbsolutePath("@storybook/addon-onboarding"),
getAbsolutePath("@storybook/addon-links"),
@@ -25,5 +30,16 @@ const config: StorybookConfig = {
name: getAbsolutePath("@storybook/react-vite"),
options: {},
},
async viteFinal(config) {
return mergeConfig(config, {
resolve: {
alias: {
preact: "react",
"preact/hooks": "react",
"preact/jsx-runtime": "react/jsx-runtime",
},
},
});
},
};
export default config;

View File

@@ -124,7 +124,7 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod
RUN npm install -g prisma
RUN npm install -g prisma@6
# Create a startup script to handle the conditional logic
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh

View File

@@ -26,6 +26,7 @@ interface ResponsePageProps {
isReadOnly: boolean;
isQuotasAllowed: boolean;
quotas: TSurveyQuota[];
initialResponses?: TResponseWithQuotas[];
}
export const ResponsePage = ({
@@ -39,11 +40,12 @@ export const ResponsePage = ({
isReadOnly,
isQuotasAllowed,
quotas,
initialResponses = [],
}: ResponsePageProps) => {
const [responses, setResponses] = useState<TResponseWithQuotas[]>([]);
const [page, setPage] = useState<number>(1);
const [hasMore, setHasMore] = useState<boolean>(true);
const [isFetchingFirstPage, setFetchingFirstPage] = useState<boolean>(true);
const [responses, setResponses] = useState<TResponseWithQuotas[]>(initialResponses);
const [page, setPage] = useState<number | null>(null);
const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage);
const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false);
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const filters = useMemo(
@@ -56,6 +58,7 @@ export const ResponsePage = ({
const searchParams = useSearchParams();
const fetchNextPage = useCallback(async () => {
if (page === null) return;
const newPage = page + 1;
let newResponses: TResponseWithQuotas[] = [];
@@ -93,10 +96,22 @@ export const ResponsePage = ({
}
}, [searchParams, resetState]);
// Only fetch if filters are applied (not on initial mount with no filters)
const hasFilters =
selectedFilter?.responseStatus !== "all" ||
(selectedFilter?.filter && selectedFilter.filter.length > 0) ||
(dateRange.from && dateRange.to);
useEffect(() => {
const fetchInitialResponses = async () => {
const fetchFilteredResponses = async () => {
try {
setFetchingFirstPage(true);
// skip call for initial mount
if (page === null && !hasFilters) {
setPage(1);
return;
}
setPage(1);
setIsFetchingFirstPage(true);
let responses: TResponseWithQuotas[] = [];
const getResponsesActionResponse = await getResponsesAction({
@@ -110,19 +125,16 @@ export const ResponsePage = ({
if (responses.length < responsesPerPage) {
setHasMore(false);
} else {
setHasMore(true);
}
setResponses(responses);
} finally {
setFetchingFirstPage(false);
setIsFetchingFirstPage(false);
}
};
fetchInitialResponses();
}, [surveyId, filters, responsesPerPage]);
useEffect(() => {
setPage(1);
setHasMore(true);
}, [filters]);
fetchFilteredResponses();
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
return (
<>

View File

@@ -3,7 +3,7 @@ import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service";
@@ -56,6 +56,9 @@ const Page = async (props) => {
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
// Fetch initial responses on the server to prevent duplicate client-side fetch
const initialResponses = await getResponses(params.surveyId, RESPONSES_PER_PAGE, 0);
return (
<PageContentWrapper>
<PageHeader
@@ -87,6 +90,7 @@ const Page = async (props) => {
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
initialResponses={initialResponses}
/>
</PageContentWrapper>
);

View File

@@ -4,7 +4,7 @@ import clsx from "clsx";
import { ChevronDown, ChevronUp, X } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TI18nString, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
@@ -26,8 +26,8 @@ import {
import { Input } from "@/modules/ui/components/input";
type QuestionFilterComboBoxProps = {
filterOptions: string[] | undefined;
filterComboBoxOptions: string[] | undefined;
filterOptions: (string | TI18nString)[] | undefined;
filterComboBoxOptions: (string | TI18nString)[] | undefined;
filterValue: string | undefined;
filterComboBoxValue: string | string[] | undefined;
onChangeFilterValue: (o: string) => void;
@@ -74,7 +74,7 @@ export const QuestionFilterComboBox = ({
if (!isMultiple) return filterComboBoxOptions;
return filterComboBoxOptions?.filter((o) => {
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
const optionValue = typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
return !filterComboBoxValue?.includes(optionValue);
});
}, [isMultiple, filterComboBoxOptions, filterComboBoxValue, defaultLanguageCode]);
@@ -91,14 +91,15 @@ export const QuestionFilterComboBox = ({
const filteredOptions = useMemo(
() =>
options?.filter((o) => {
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
const optionValue =
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
return optionValue.toLowerCase().includes(searchQuery.toLowerCase());
}),
[options, searchQuery, defaultLanguageCode]
);
const handleCommandItemSelect = (o: string) => {
const value = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
const handleCommandItemSelect = (o: string | TI18nString) => {
const value = typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
if (isMultiple) {
const newValue = Array.isArray(filterComboBoxValue) ? [...filterComboBoxValue, value] : [value];
@@ -200,14 +201,18 @@ export const QuestionFilterComboBox = ({
)}
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-white">
{filterOptions?.map((o, index) => (
<DropdownMenuItem
key={`${o}-${index}`}
className="cursor-pointer"
onClick={() => onChangeFilterValue(o)}>
{o}
</DropdownMenuItem>
))}
{filterOptions?.map((o, index) => {
const optionValue =
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
return (
<DropdownMenuItem
key={`${optionValue}-${index}`}
className="cursor-pointer"
onClick={() => onChangeFilterValue(optionValue)}>
{optionValue}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -269,7 +274,8 @@ export const QuestionFilterComboBox = ({
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
<CommandGroup>
{filteredOptions?.map((o) => {
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
const optionValue =
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
return (
<CommandItem
key={optionValue}

View File

@@ -4,7 +4,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TI18nString, TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
SelectedFilterValue,
TResponseStatus,
@@ -13,6 +13,7 @@ import {
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { QuestionFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox";
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { Button } from "@/modules/ui/components/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import {
@@ -25,9 +26,17 @@ import {
import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsComboBox";
export type QuestionFilterOptions = {
type: TSurveyQuestionTypeEnum | "Attributes" | "Tags" | "Languages" | "Quotas";
filterOptions: string[];
filterComboBoxOptions: string[];
type:
| TSurveyQuestionTypeEnum
| "Attributes"
| "Tags"
| "Languages"
| "Quotas"
| "Hidden Fields"
| "Meta"
| OptionsType.OTHERS;
filterOptions: (string | TI18nString)[];
filterComboBoxOptions: (string | TI18nString)[];
id: string;
};
@@ -69,6 +78,12 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [filterValue, setFilterValue] = useState<SelectedFilterValue>(selectedFilter);
const getDefaultFilterValue = (option?: QuestionFilterOptions): string | undefined => {
if (!option || option.filterOptions.length === 0) return undefined;
const firstOption = option.filterOptions[0];
return typeof firstOption === "object" ? getLocalizedValue(firstOption, "default") : firstOption;
};
useEffect(() => {
// Fetch the initial data for the filter and load it into the state
const handleInitialData = async () => {
@@ -94,15 +109,18 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
}, [isOpen, setSelectedOptions, survey]);
const handleOnChangeQuestionComboBoxValue = (value: QuestionOption, index: number) => {
const matchingFilterOption = selectedOptions.questionFilterOptions.find(
(q) => q.type === value.type || q.type === value.questionType
);
const defaultFilterValue = getDefaultFilterValue(matchingFilterOption);
if (filterValue.filter[index].questionType) {
// Create a new array and copy existing values from SelectedFilter
filterValue.filter[index] = {
questionType: value,
filterType: {
filterComboBoxValue: undefined,
filterValue: selectedOptions.questionFilterOptions.find(
(q) => q.type === value.type || q.type === value.questionType
)?.filterOptions[0],
filterValue: defaultFilterValue,
},
};
setFilterValue({ filter: [...filterValue.filter], responseStatus: filterValue.responseStatus });
@@ -111,9 +129,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
filterValue.filter[index].questionType = value;
filterValue.filter[index].filterType = {
filterComboBoxValue: undefined,
filterValue: selectedOptions.questionFilterOptions.find(
(q) => q.type === value.type || q.type === value.questionType
)?.filterOptions[0],
filterValue: defaultFilterValue,
};
setFilterValue({ ...filterValue });
}

View File

@@ -213,8 +213,8 @@ describe("surveys", () => {
id: "q8",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix" },
rows: [{ id: "r1", label: "Row 1" }],
columns: [{ id: "c1", label: "Column 1" }],
rows: [{ id: "r1", label: { default: "Row 1" } }],
columns: [{ id: "c1", label: { default: "Column 1" } }],
} as unknown as TSurveyQuestion,
],
createdAt: new Date(),

View File

@@ -76,9 +76,9 @@ export const generateQuestionAndFilterOptions = (
questionFilterOptions: QuestionFilterOptions[];
} => {
let questionOptions: QuestionOptions[] = [];
let questionFilterOptions: any = [];
let questionFilterOptions: QuestionFilterOptions[] = [];
let questionsOptions: any = [];
let questionsOptions: QuestionOption[] = [];
survey.questions.forEach((q) => {
if (Object.keys(conditionOptions).includes(q.type)) {
@@ -121,8 +121,8 @@ export const generateQuestionAndFilterOptions = (
} else if (q.type === TSurveyQuestionTypeEnum.Matrix) {
questionFilterOptions.push({
type: q.type,
filterOptions: q.rows.flatMap((row) => Object.values(row)),
filterComboBoxOptions: q.columns.flatMap((column) => Object.values(column)),
filterOptions: q.rows.map((row) => getLocalizedValue(row.label, "default")),
filterComboBoxOptions: q.columns.map((column) => getLocalizedValue(column.label, "default")),
id: q.id,
});
} else {

View File

@@ -1504,7 +1504,7 @@ const docsFeedback = (t: TFunction): TTemplate => {
buildOpenTextQuestion({
headline: t("templates.docs_feedback_question_2_headline"),
required: false,
inputType: "text",
inputType: "url",
t,
}),
buildOpenTextQuestion({

View File

@@ -1252,7 +1252,7 @@ checksums:
environments/surveys/edit/edit_link: 40ba9e15beac77a46c5baf30be84ac54
environments/surveys/edit/edit_recall: 38a4a7378d02453e35d06f2532eef318
environments/surveys/edit/edit_translations: 2b21bea4b53e88342559272701e9fbf3
environments/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: 71977f91ec151b61ee3528ac2618afed
environments/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: c70466147d49dcbb3686452f35c46428
environments/surveys/edit/enable_recaptcha_to_protect_your_survey_from_spam: 4483a5763718d201ac97caa1e1216e13
environments/surveys/edit/enable_spam_protection: e1fb0dd0723044bf040b92d8fc58015d
environments/surveys/edit/end_screen_card: 6146c2bcb87291e25ecb03abd2d9a479

View File

@@ -1337,7 +1337,7 @@
"edit_link": "Bearbeitungslink",
"edit_recall": "Erinnerung bearbeiten",
"edit_translations": "{lang} -Übersetzungen bearbeiten",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Teilnehmer können die Umfragesprache jederzeit während der Umfrage ändern.",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Befragten erlauben, die Sprache jederzeit zu wechseln. Benötigt mind. 2 aktive Sprachen.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Spamschutz verwendet reCAPTCHA v3, um Spam-Antworten herauszufiltern.",
"enable_spam_protection": "Spamschutz",
"end_screen_card": "Abschluss-Karte",

View File

@@ -1337,7 +1337,7 @@
"edit_link": "Edit link",
"edit_recall": "Edit Recall",
"edit_translations": "Edit {lang} translations",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Enable participants to switch the survey language at any point during the survey.",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Allow respondents to switch language at any time. Needs min. 2 active languages.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Spam protection uses reCAPTCHA v3 to filter out the spam responses.",
"enable_spam_protection": "Spam protection",
"end_screen_card": "End screen card",

View File

@@ -1337,7 +1337,7 @@
"edit_link": "Modifier le lien",
"edit_recall": "Modifier le rappel",
"edit_translations": "Modifier les traductions {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permettre aux participants de changer la langue de l'enquête à tout moment pendant celle-ci.",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permettre aux répondants de changer de langue à tout moment. Nécessite au moins 2 langues actives.",
"enable_recaptcha_to_protect_your_survey_from_spam": "La protection contre le spam utilise reCAPTCHA v3 pour filtrer les réponses indésirables.",
"enable_spam_protection": "Protection contre le spam",
"end_screen_card": "Carte de fin d'écran",

View File

@@ -1337,7 +1337,7 @@
"edit_link": "編集 リンク",
"edit_recall": "リコールを編集",
"edit_translations": "{lang} 翻訳を編集",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "回答者がフォームの途中でいつでも言語を切り替えられるようにします。",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "回答者がいつでも言語を切り替えられるようにします。最低2つのアクティブな言語が必要です。",
"enable_recaptcha_to_protect_your_survey_from_spam": "スパム対策はreCAPTCHA v3を使用してスパム回答をフィルタリングします。",
"enable_spam_protection": "スパム対策",
"end_screen_card": "終了画面カード",

View File

@@ -1337,7 +1337,7 @@
"edit_link": "Link bewerken",
"edit_recall": "Bewerken Terugroepen",
"edit_translations": "Bewerk {lang} vertalingen",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Geef deelnemers de mogelijkheid om op elk moment tijdens de enquête van enquêtetaal te wisselen.",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Sta respondenten toe om op elk moment van taal te wisselen. Vereist min. 2 actieve talen.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Spambeveiliging maakt gebruik van reCAPTCHA v3 om de spamreacties eruit te filteren.",
"enable_spam_protection": "Spambescherming",
"end_screen_card": "Eindschermkaart",

View File

@@ -1337,7 +1337,7 @@
"edit_link": "Editar link",
"edit_recall": "Editar Lembrete",
"edit_translations": "Editar traduções de {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os participantes mudem o idioma da pesquisa a qualquer momento durante a pesquisa.",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os respondentes alterem o idioma a qualquer momento. Necessita de no mínimo 2 idiomas ativos.",
"enable_recaptcha_to_protect_your_survey_from_spam": "A proteção contra spam usa o reCAPTCHA v3 para filtrar as respostas de spam.",
"enable_spam_protection": "Proteção contra spam",
"end_screen_card": "cartão de tela final",

View File

@@ -1337,7 +1337,7 @@
"edit_link": "Editar link",
"edit_recall": "Editar Lembrete",
"edit_translations": "Editar traduções {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir aos participantes mudar a língua do inquérito a qualquer momento durante o inquérito.",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os inquiridos mudem de idioma a qualquer momento. Necessita de pelo menos 2 idiomas ativos.",
"enable_recaptcha_to_protect_your_survey_from_spam": "A proteção contra spam usa o reCAPTCHA v3 para filtrar as respostas de spam.",
"enable_spam_protection": "Proteção contra spam",
"end_screen_card": "Cartão de ecrã final",

View File

@@ -1337,7 +1337,7 @@
"edit_link": "Editare legătură",
"edit_recall": "Editează Referințele",
"edit_translations": "Editează traducerile {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permite participanților să schimbe limba sondajului în orice moment în timpul sondajului.",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permite respondenților să schimbe limba în orice moment. Necesită minimum 2 limbi active.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Protecția împotriva spamului folosește reCAPTCHA v3 pentru a filtra răspunsurile de spam.",
"enable_spam_protection": "Protecția împotriva spamului",
"end_screen_card": "Ecran final card",

View File

@@ -1337,7 +1337,7 @@
"edit_link": "编辑 链接",
"edit_recall": "编辑 调用",
"edit_translations": "编辑 {lang} 翻译",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "启用 参与者 在 调查 过程中 的 任何 时间 点 切换 调查 语言。",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允许受访者在调查过程中随时切换语言。需要至少启用两种语言。",
"enable_recaptcha_to_protect_your_survey_from_spam": "垃圾 邮件 保护 使用 reCAPTCHA v3 来 过滤 掉 垃圾 响应 。",
"enable_spam_protection": "垃圾 邮件 保护",
"end_screen_card": "结束 屏幕 卡片",

View File

@@ -1337,7 +1337,7 @@
"edit_link": "編輯 連結",
"edit_recall": "編輯回憶",
"edit_translations": "編輯 '{'language'}' 翻譯",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允許參與者在問卷中的任何時間點切換問卷語言。",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允許受訪者隨時切換語言。需要至少啟用兩種語言。",
"enable_recaptcha_to_protect_your_survey_from_spam": "垃圾郵件保護使用 reCAPTCHA v3 過濾垃圾回應。",
"enable_spam_protection": "垃圾郵件保護",
"end_screen_card": "結束畫面卡片",

View File

@@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
import type { TSurvey, TSurveyLanguage, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { addMultiLanguageLabels, extractLanguageCodes } from "@/lib/i18n/utils";
import { addMultiLanguageLabels, extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
@@ -177,6 +177,8 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
const [parent] = useAutoAnimate();
const enabledLanguages = getEnabledLanguages(localSurvey.languages);
return (
<div
className={cn(
@@ -300,6 +302,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
<AdvancedOptionToggle
customContainerClass="px-0 pt-0"
htmlId="languageSwitch"
disabled={enabledLanguages.length <= 1}
isChecked={!!localSurvey.showLanguageSwitch}
onToggle={handleLanguageSwitchToggle}
title={t("environments.surveys.edit.show_language_switch")}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import toast from "react-hot-toast";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
@@ -30,10 +30,6 @@ export const useSingleUseId = (survey: TSurvey | TSurveyList, isReadOnly: boolea
}
}, [survey, isReadOnly]);
useEffect(() => {
refreshSingleUseId();
}, [refreshSingleUseId]);
return {
singleUseId: isReadOnly ? undefined : singleUseId,
refreshSingleUseId: isReadOnly ? async () => undefined : refreshSingleUseId,

View File

@@ -45,11 +45,11 @@ export const selectSurvey = {
language: {
select: {
id: true,
code: true,
alias: true,
createdAt: true,
updatedAt: true,
code: true,
projectId: true,
alias: true,
},
},
},
@@ -72,7 +72,15 @@ export const selectSurvey = {
},
},
segment: {
include: {
select: {
id: true,
createdAt: true,
updatedAt: true,
environmentId: true,
title: true,
description: true,
isPrivate: true,
filters: true,
surveys: {
select: {
id: true,

View File

@@ -3,17 +3,17 @@
import { Project, Response } from "@prisma/client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TProjectStyling } from "@formbricks/types/project";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { validateSurveyPinAction } from "@/modules/survey/link/actions";
import { LinkSurvey } from "@/modules/survey/link/components/link-survey";
import { SurveyClientWrapper } from "@/modules/survey/link/components/survey-client-wrapper";
import { OTPInput } from "@/modules/ui/components/otp-input";
interface PinScreenProps {
surveyId: string;
project: Pick<Project, "styling" | "logo" | "linkSurveyBranding">;
emailVerificationStatus?: string;
singleUseId?: string;
singleUseResponse?: Pick<Response, "id" | "finished">;
publicDomain: string;
@@ -23,11 +23,12 @@ interface PinScreenProps {
verifiedEmail?: string;
languageCode: string;
isEmbed: boolean;
locale: string;
isPreview: boolean;
contactId?: string;
recaptchaSiteKey?: string;
isSpamProtectionEnabled?: boolean;
responseCount?: number;
styling: TProjectStyling | TSurveyStyling;
}
export const PinScreen = (props: PinScreenProps) => {
@@ -35,7 +36,6 @@ export const PinScreen = (props: PinScreenProps) => {
surveyId,
project,
publicDomain,
emailVerificationStatus,
singleUseId,
singleUseResponse,
IMPRINT_URL,
@@ -44,11 +44,12 @@ export const PinScreen = (props: PinScreenProps) => {
verifiedEmail,
languageCode,
isEmbed,
locale,
isPreview,
contactId,
recaptchaSiteKey,
isSpamProtectionEnabled = false,
responseCount,
styling,
} = props;
const [localPinEntry, setLocalPinEntry] = useState<string>("");
@@ -116,24 +117,24 @@ export const PinScreen = (props: PinScreenProps) => {
}
return (
<LinkSurvey
<SurveyClientWrapper
survey={survey}
project={project}
emailVerificationStatus={emailVerificationStatus}
singleUseId={singleUseId}
singleUseResponse={singleUseResponse}
styling={styling}
publicDomain={publicDomain}
verifiedEmail={verifiedEmail}
responseCount={responseCount}
languageCode={languageCode}
isEmbed={isEmbed}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
locale={locale}
isPreview={isPreview}
singleUseId={singleUseId}
singleUseResponseId={singleUseResponse?.id}
contactId={contactId}
recaptchaSiteKey={recaptchaSiteKey}
isSpamProtectionEnabled={isSpamProtectionEnabled}
isPreview={isPreview}
verifiedEmail={verifiedEmail}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
/>
);
};

View File

@@ -1,160 +1,110 @@
"use client";
import { Project, Response } from "@prisma/client";
import { Project } from "@prisma/client";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { TResponseData, TResponseHiddenFieldValue } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TProjectStyling } from "@formbricks/types/project";
import { TResponseData } from "@formbricks/types/responses";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-wrapper";
import { SurveyLinkUsed } from "@/modules/survey/link/components/survey-link-used";
import { VerifyEmail } from "@/modules/survey/link/components/verify-email";
import { getPrefillValue } from "@/modules/survey/link/lib/utils";
import { SurveyInline } from "@/modules/ui/components/survey";
let setQuestionId = (_: string) => {};
let setResponseData = (_: TResponseData) => {};
interface LinkSurveyProps {
interface SurveyClientWrapperProps {
survey: TSurvey;
project: Pick<Project, "styling" | "logo" | "linkSurveyBranding">;
emailVerificationStatus?: string;
singleUseId?: string;
singleUseResponse?: Pick<Response, "id" | "finished">;
styling: TProjectStyling | TSurveyStyling;
publicDomain: string;
responseCount?: number;
verifiedEmail?: string;
languageCode: string;
isEmbed: boolean;
singleUseId?: string;
singleUseResponseId?: string;
contactId?: string;
recaptchaSiteKey?: string;
isSpamProtectionEnabled: boolean;
isPreview: boolean;
verifiedEmail?: string;
IMPRINT_URL?: string;
PRIVACY_URL?: string;
IS_FORMBRICKS_CLOUD: boolean;
locale: string;
isPreview: boolean;
contactId?: string;
recaptchaSiteKey?: string;
isSpamProtectionEnabled?: boolean;
}
export const LinkSurvey = ({
// Module-level functions to allow SurveyInline to control survey state
let setQuestionId = (_: string) => {};
let setResponseData = (_: TResponseData) => {};
export const SurveyClientWrapper = ({
survey,
project,
emailVerificationStatus,
singleUseId,
singleUseResponse,
styling,
publicDomain,
responseCount,
verifiedEmail,
languageCode,
isEmbed,
singleUseId,
singleUseResponseId,
contactId,
recaptchaSiteKey,
isSpamProtectionEnabled,
isPreview,
verifiedEmail,
IMPRINT_URL,
PRIVACY_URL,
IS_FORMBRICKS_CLOUD,
locale,
isPreview,
contactId,
recaptchaSiteKey,
isSpamProtectionEnabled = false,
}: LinkSurveyProps) => {
const responseId = singleUseResponse?.id;
}: SurveyClientWrapperProps) => {
const searchParams = useSearchParams();
const skipPrefilled = searchParams.get("skipPrefilled") === "true";
const suId = searchParams.get("suId");
const startAt = searchParams.get("startAt");
// Extract survey properties outside useMemo to create stable references
const welcomeCardEnabled = survey.welcomeCard.enabled;
const surveyQuestions = survey.questions;
// Validate startAt parameter against survey questions
const isStartAtValid = useMemo(() => {
if (!startAt) return false;
if (survey.welcomeCard.enabled && startAt === "start") return true;
if (welcomeCardEnabled && startAt === "start") return true;
const isValid = surveyQuestions.some((q) => q.id === startAt);
const isValid = survey.questions.some((question) => question.id === startAt);
// To remove startAt query param from URL if it is not valid:
if (!isValid && typeof window !== "undefined") {
const url = new URL(window.location.href);
// Clean up invalid startAt from URL to prevent confusion
if (!isValid && globalThis.window !== undefined) {
const url = new URL(globalThis.location.href);
url.searchParams.delete("startAt");
window.history.replaceState({}, "", url.toString());
globalThis.history.replaceState({}, "", url.toString());
}
return isValid;
}, [survey, startAt]);
}, [welcomeCardEnabled, surveyQuestions, startAt]);
const prefillValue = getPrefillValue(survey, searchParams, languageCode);
const [autoFocus, setAutoFocus] = useState(false);
const hasFinishedSingleUseResponse = useMemo(() => {
if (singleUseResponse?.finished) {
return true;
}
return false;
// eslint-disable-next-line react-hooks/exhaustive-deps -- only run once
}, []);
// Not in an iframe, enable autofocus on input fields.
// Enable autofocus only when not in iframe
useEffect(() => {
if (window.self === window.top) {
if (globalThis.self === globalThis.top) {
setAutoFocus(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- only run once
}, []);
const hiddenFieldsRecord = useMemo<TResponseHiddenFieldValue>(() => {
const fieldsRecord: TResponseHiddenFieldValue = {};
survey.hiddenFields.fieldIds?.forEach((field) => {
// Extract hidden fields from URL parameters
const hiddenFieldsRecord = useMemo(() => {
const fieldsRecord: Record<string, string> = {};
for (const field of survey.hiddenFields.fieldIds || []) {
const answer = searchParams.get(field);
if (answer) {
fieldsRecord[field] = answer;
}
});
if (answer) fieldsRecord[field] = answer;
}
return fieldsRecord;
}, [searchParams, survey.hiddenFields.fieldIds]);
}, [searchParams, JSON.stringify(survey.hiddenFields.fieldIds || [])]);
// Include verified email in hidden fields if available
const getVerifiedEmail = useMemo<Record<string, string> | null>(() => {
if (survey.isVerifyEmailEnabled && verifiedEmail) {
return { verifiedEmail: verifiedEmail };
} else {
return null;
}
return null;
}, [survey.isVerifyEmailEnabled, verifiedEmail]);
if (hasFinishedSingleUseResponse) {
return <SurveyLinkUsed singleUseMessage={survey.singleUse} project={project} />;
}
if (survey.isVerifyEmailEnabled && emailVerificationStatus !== "verified" && !isPreview) {
if (emailVerificationStatus === "fishy") {
return (
<VerifyEmail
survey={survey}
isErrorComponent={true}
languageCode={languageCode}
styling={project.styling}
locale={locale}
/>
);
}
//emailVerificationStatus === "not-verified"
return (
<VerifyEmail
singleUseId={suId ?? ""}
survey={survey}
languageCode={languageCode}
styling={project.styling}
locale={locale}
/>
);
}
const determineStyling = () => {
// Check if style overwrite is disabled at the project level
if (!project.styling.allowStyleOverwrite) {
return project.styling;
}
// Return survey styling if survey overwrites are enabled, otherwise return project styling
return survey.styling?.overwriteThemeStyling ? survey.styling : project.styling;
};
const handleResetSurvey = () => {
setQuestionId(survey.welcomeCard.enabled ? "start" : survey.questions[0].id);
setResponseData({});
@@ -167,8 +117,8 @@ export const LinkSurvey = ({
isWelcomeCardEnabled={survey.welcomeCard.enabled}
isPreview={isPreview}
surveyType={survey.type}
determineStyling={() => styling}
handleResetSurvey={handleResetSurvey}
determineStyling={determineStyling}
isEmbed={isEmbed}
publicDomain={publicDomain}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
@@ -180,11 +130,10 @@ export const LinkSurvey = ({
environmentId={survey.environmentId}
isPreviewMode={isPreview}
survey={survey}
styling={determineStyling()}
styling={styling}
languageCode={languageCode}
isBrandingEnabled={project.linkSurveyBranding}
shouldResetQuestionId={false}
// eslint-disable-next-line jsx-a11y/no-autofocus -- need it as focus behaviour is different in normal surveys and survey preview
autoFocus={autoFocus}
prefillResponseData={prefillValue}
skipPrefilled={skipPrefilled}
@@ -202,7 +151,7 @@ export const LinkSurvey = ({
...getVerifiedEmail,
}}
singleUseId={singleUseId}
singleUseResponseId={responseId}
singleUseResponseId={singleUseResponseId}
getSetIsResponseSendingFinished={(_f: (value: boolean) => void) => {}}
contactId={contactId}
recaptchaSiteKey={recaptchaSiteKey}

View File

@@ -1,22 +1,21 @@
"use client";
import { Project } from "@prisma/client";
import { CheckCircle2Icon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TSurveySingleUse } from "@formbricks/types/surveys/types";
import { getTranslate } from "@/lingodotdev/server";
import footerLogo from "../lib/footerlogo.svg";
interface SurveyLinkUsedProps {
interface SurveyCompletedMessageProps {
singleUseMessage: TSurveySingleUse | null;
project?: Pick<Project, "linkSurveyBranding">;
}
export const SurveyLinkUsed = ({ singleUseMessage, project }: SurveyLinkUsedProps) => {
const { t } = useTranslation();
export const SurveyCompletedMessage = async ({ singleUseMessage, project }: SurveyCompletedMessageProps) => {
const t = await getTranslate();
const defaultHeading = t("s.survey_already_answered_heading");
const defaultSubheading = t("s.survey_already_answered_subheading");
return (
<div className="flex min-h-screen flex-col items-center justify-between bg-gradient-to-tr from-slate-200 to-slate-50 py-8 text-center">
<div className="my-auto flex flex-col items-center space-y-3 text-slate-300">

View File

@@ -1,6 +1,8 @@
import { type Response } from "@prisma/client";
import { notFound } from "next/navigation";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TProjectStyling } from "@formbricks/types/project";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import {
IMPRINT_URL,
IS_FORMBRICKS_CLOUD,
@@ -9,16 +11,13 @@ import {
RECAPTCHA_SITE_KEY,
} from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { LinkSurvey } from "@/modules/survey/link/components/link-survey";
import { PinScreen } from "@/modules/survey/link/components/pin-screen";
import { SurveyClientWrapper } from "@/modules/survey/link/components/survey-client-wrapper";
import { SurveyCompletedMessage } from "@/modules/survey/link/components/survey-completed-message";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { VerifyEmail } from "@/modules/survey/link/components/verify-email";
import { TEnvironmentContextForLinkSurvey } from "@/modules/survey/link/lib/environment";
import { getEmailVerificationDetails } from "@/modules/survey/link/lib/helper";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
interface SurveyRendererProps {
survey: TSurvey;
@@ -27,13 +26,31 @@ interface SurveyRendererProps {
lang?: string;
embed?: string;
preview?: string;
suId?: string;
};
singleUseId?: string;
singleUseResponse?: Pick<Response, "id" | "finished"> | undefined;
singleUseResponse?: Pick<Response, "id" | "finished">;
contactId?: string;
isPreview: boolean;
// New props - pre-fetched in parent
environmentContext: TEnvironmentContextForLinkSurvey;
locale: TUserLocale;
isMultiLanguageAllowed: boolean;
responseCount?: number;
}
/**
* Renders link survey with pre-fetched data from parent.
*
* This function receives all necessary data as props to avoid additional
* database queries. The parent (page.tsx) fetches data in parallel stages
* to minimize latency for users geographically distant from servers.
*
* @param environmentContext - Pre-fetched project and organization data
* @param locale - User's locale from Accept-Language header
* @param isMultiLanguageAllowed - Calculated from organization billing plan
* @param responseCount - Conditionally fetched if showResponseCount is enabled
*/
export const renderSurvey = async ({
survey,
searchParams,
@@ -41,8 +58,11 @@ export const renderSurvey = async ({
singleUseResponse,
contactId,
isPreview,
environmentContext,
locale,
isMultiLanguageAllowed,
responseCount,
}: SurveyRendererProps) => {
const locale = await findMatchingLocale();
const langParam = searchParams.lang;
const isEmbed = searchParams.embed === "true";
@@ -50,27 +70,27 @@ export const renderSurvey = async ({
notFound();
}
const organizationId = await getOrganizationIdFromEnvironmentId(survey.environmentId);
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new Error("Organization not found");
}
const isMultiLanguageAllowed = await getMultiLanguagePermission(organizationBilling.plan);
// Extract project from pre-fetched context
const { project } = environmentContext;
const isSpamProtectionEnabled = Boolean(IS_RECAPTCHA_CONFIGURED && survey.recaptcha?.enabled);
if (survey.status !== "inProgress") {
const project = await getProjectByEnvironmentId(survey.environmentId);
return (
<SurveyInactive
status={survey.status}
surveyClosedMessage={survey.surveyClosedMessage}
project={project || undefined}
project={project}
/>
);
}
// verify email: Check if the survey requires email verification
// Check if single-use survey has already been completed
if (singleUseResponse?.finished) {
return <SurveyCompletedMessage singleUseMessage={survey.singleUse} project={project} />;
}
// Handle email verification flow if enabled
let emailVerificationStatus = "";
let verifiedEmail: string | undefined = undefined;
@@ -84,40 +104,42 @@ export const renderSurvey = async ({
}
}
// get project
const project = await getProjectByEnvironmentId(survey.environmentId);
if (!project) {
throw new Error("Project not found");
if (survey.isVerifyEmailEnabled && emailVerificationStatus !== "verified" && !isPreview) {
if (emailVerificationStatus === "fishy") {
return (
<VerifyEmail
survey={survey}
isErrorComponent={true}
languageCode={getLanguageCode(langParam, isMultiLanguageAllowed, survey)}
styling={project.styling}
locale={locale}
/>
);
}
return (
<VerifyEmail
singleUseId={searchParams.suId ?? ""}
survey={survey}
languageCode={getLanguageCode(langParam, isMultiLanguageAllowed, survey)}
styling={project.styling}
locale={locale}
/>
);
}
const getLanguageCode = (): string => {
if (!langParam || !isMultiLanguageAllowed) return "default";
else {
const selectedLanguage = survey.languages.find((surveyLanguage) => {
return (
surveyLanguage.language.code === langParam.toLowerCase() ||
surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase()
);
});
if (!selectedLanguage || selectedLanguage?.default || !selectedLanguage?.enabled) {
return "default";
}
return selectedLanguage.language.code;
}
};
const languageCode = getLanguageCode();
const isSurveyPinProtected = Boolean(survey.pin);
const responseCount = await getResponseCountBySurveyId(survey.id);
// Compute final styling based on project and survey settings
const styling = computeStyling(project.styling, survey.styling);
const languageCode = getLanguageCode(langParam, isMultiLanguageAllowed, survey);
const publicDomain = getPublicDomain();
if (isSurveyPinProtected) {
// Handle PIN-protected surveys
if (survey.pin) {
return (
<PinScreen
surveyId={survey.id}
styling={styling}
publicDomain={publicDomain}
project={project}
emailVerificationStatus={emailVerificationStatus}
singleUseId={singleUseId}
singleUseResponse={singleUseResponse}
IMPRINT_URL={IMPRINT_URL}
@@ -126,35 +148,74 @@ export const renderSurvey = async ({
verifiedEmail={verifiedEmail}
languageCode={languageCode}
isEmbed={isEmbed}
locale={locale}
isPreview={isPreview}
contactId={contactId}
recaptchaSiteKey={RECAPTCHA_SITE_KEY}
isSpamProtectionEnabled={isSpamProtectionEnabled}
responseCount={responseCount}
/>
);
}
// Render interactive survey with client component for interactivity
return (
<LinkSurvey
<SurveyClientWrapper
survey={survey}
project={project}
styling={styling}
publicDomain={publicDomain}
emailVerificationStatus={emailVerificationStatus}
singleUseId={singleUseId}
singleUseResponse={singleUseResponse}
responseCount={survey.welcomeCard.showResponseCount ? responseCount : undefined}
verifiedEmail={verifiedEmail}
responseCount={responseCount}
languageCode={languageCode}
isEmbed={isEmbed}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
locale={locale}
isPreview={isPreview}
singleUseId={singleUseId}
singleUseResponseId={singleUseResponse?.id}
contactId={contactId}
recaptchaSiteKey={RECAPTCHA_SITE_KEY}
isSpamProtectionEnabled={isSpamProtectionEnabled}
isPreview={isPreview}
verifiedEmail={verifiedEmail}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
/>
);
};
/**
* Determines which styling to use based on project and survey settings.
* Returns survey styling if theme overwriting is enabled, otherwise returns project styling.
*/
function computeStyling(
projectStyling: TProjectStyling,
surveyStyling?: TSurveyStyling | null
): TProjectStyling | TSurveyStyling {
if (!projectStyling.allowStyleOverwrite) {
return projectStyling;
}
return surveyStyling?.overwriteThemeStyling ? surveyStyling : projectStyling;
}
/**
* Determines the language code to use for the survey.
* Checks URL parameter against available survey languages and returns
* "default" if multi-language is not allowed or language is not found.
*/
function getLanguageCode(
langParam: string | undefined,
isMultiLanguageAllowed: boolean,
survey: TSurvey
): string {
if (!langParam || !isMultiLanguageAllowed) return "default";
const selectedLanguage = survey.languages.find((surveyLanguage) => {
return (
surveyLanguage.language.code === langParam.toLowerCase() ||
surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase()
);
});
if (!selectedLanguage || selectedLanguage?.default || !selectedLanguage?.enabled) {
return "default";
}
return selectedLanguage.language.code;
}

View File

@@ -1,11 +1,15 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { verifyContactSurveyToken } from "@/modules/ee/contacts/lib/contact-survey-link";
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
import { getSurvey } from "@/modules/survey/lib/survey";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
import { getExistingContactResponse } from "@/modules/survey/link/lib/data";
import { getEnvironmentContextForLinkSurvey } from "@/modules/survey/link/lib/environment";
import { checkAndValidateSingleUseId } from "@/modules/survey/link/lib/helper";
import { getBasicSurveyMetadata } from "@/modules/survey/link/lib/metadata-utils";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
@@ -93,18 +97,41 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
if (isSingleUseSurvey) {
const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted);
if (!validatedSingleUseId) {
const project = await getProjectByEnvironmentId(survey.environmentId);
return <SurveyInactive status="link invalid" project={project ?? undefined} />;
const environmentContext = await getEnvironmentContextForLinkSurvey(survey.environmentId);
return <SurveyInactive status="link invalid" project={environmentContext.project} />;
}
singleUseId = validatedSingleUseId;
}
// Parallel fetch of environment context and locale
const [environmentContext, locale, singleUseResponse] = await Promise.all([
getEnvironmentContextForLinkSurvey(survey.environmentId),
findMatchingLocale(),
// Fetch existing response for this contact
getExistingContactResponse(survey.id, contactId)(),
]);
// Get multi-language permission
const isMultiLanguageAllowed = await getMultiLanguagePermission(
environmentContext.organizationBilling.plan
);
// Fetch responseCount only if needed
const responseCount = survey.welcomeCard.showResponseCount
? await getResponseCountBySurveyId(survey.id)
: undefined;
return renderSurvey({
survey,
searchParams,
contactId,
isPreview,
singleUseId,
singleUseResponse,
environmentContext,
locale,
isMultiLanguageAllowed,
responseCount,
});
};

View File

@@ -398,7 +398,7 @@ describe("data", () => {
});
});
test("should return null when contact response not found", async () => {
test("should return undefined when contact response not found", async () => {
const surveyId = "survey-1";
const contactId = "nonexistent-contact";
@@ -406,7 +406,7 @@ describe("data", () => {
const result = await getExistingContactResponse(surveyId, contactId)();
expect(result).toBeNull();
expect(result).toBeUndefined();
});
test("should throw DatabaseError on Prisma error", async () => {

View File

@@ -57,6 +57,7 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
surveyClosedMessage: true,
showLanguageSwitch: true,
recaptcha: true,
metadata: true,
// Related data
languages: {
@@ -66,11 +67,11 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
language: {
select: {
id: true,
code: true,
alias: true,
createdAt: true,
updatedAt: true,
code: true,
projectId: true,
alias: true,
},
},
},
@@ -93,7 +94,15 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
},
},
segment: {
include: {
select: {
id: true,
createdAt: true,
updatedAt: true,
environmentId: true,
title: true,
description: true,
isPrivate: true,
filters: true,
surveys: {
select: {
id: true,
@@ -208,7 +217,7 @@ export const getExistingContactResponse = reactCache((surveyId: string, contactI
},
});
return response;
return response ?? undefined;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);

View File

@@ -0,0 +1,221 @@
import { Prisma } from "@prisma/client";
import "@testing-library/jest-dom/vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { getEnvironmentContextForLinkSurvey } from "./environment";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
environment: {
findUnique: vi.fn(),
},
},
}));
// Mock React cache
vi.mock("react", () => ({
cache: vi.fn((fn) => fn),
}));
describe("getEnvironmentContextForLinkSurvey", () => {
beforeEach(() => {
vi.resetAllMocks();
});
test("should successfully fetch environment context with all required data", async () => {
const mockEnvironmentId = "clh1a2b3c4d5e6f7g8h9i";
const mockData = {
project: {
id: "clh1a2b3c4d5e6f7g8h9j",
name: "Test Project",
styling: { primaryColor: "#000000" },
logo: { url: "https://example.com/logo.png" },
linkSurveyBranding: true,
organizationId: "clh1a2b3c4d5e6f7g8h9k",
organization: {
id: "clh1a2b3c4d5e6f7g8h9k",
billing: {
plan: "free",
limits: {
monthly: {
responses: 100,
miu: 1000,
},
},
features: {
inAppSurvey: {
status: "active",
},
linkSurvey: {
status: "active",
},
},
},
},
},
};
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockData as any);
const result = await getEnvironmentContextForLinkSurvey(mockEnvironmentId);
expect(result).toEqual({
project: {
id: "clh1a2b3c4d5e6f7g8h9j",
name: "Test Project",
styling: { primaryColor: "#000000" },
logo: { url: "https://example.com/logo.png" },
linkSurveyBranding: true,
},
organizationId: "clh1a2b3c4d5e6f7g8h9k",
organizationBilling: mockData.project.organization.billing,
});
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
where: { id: mockEnvironmentId },
select: {
project: {
select: {
id: true,
name: true,
styling: true,
logo: true,
linkSurveyBranding: true,
organizationId: true,
organization: {
select: {
id: true,
billing: true,
},
},
},
},
},
});
});
test("should throw ValidationError for invalid environment ID", async () => {
const invalidId = "invalid-id";
await expect(getEnvironmentContextForLinkSurvey(invalidId)).rejects.toThrow(ValidationError);
});
test("should throw ResourceNotFoundError when environment has no project", async () => {
const mockEnvironmentId = "clh1a2b3c4d5e6f7g8h9m";
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
project: null,
} as any);
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow(
ResourceNotFoundError
);
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow("Project");
});
test("should throw ResourceNotFoundError when environment is not found", async () => {
const mockEnvironmentId = "cuid123456789012345";
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow(
ResourceNotFoundError
);
});
test("should throw ResourceNotFoundError when project has no organization", async () => {
const mockEnvironmentId = "clh1a2b3c4d5e6f7g8h9n";
const mockData = {
project: {
id: "clh1a2b3c4d5e6f7g8h9o",
name: "Test Project",
styling: {},
logo: null,
linkSurveyBranding: true,
organizationId: "clh1a2b3c4d5e6f7g8h9p",
organization: null,
},
};
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockData as any);
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow(
ResourceNotFoundError
);
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow("Organization");
});
test("should throw DatabaseError on Prisma error", async () => {
const mockEnvironmentId = "clh1a2b3c4d5e6f7g8h9q";
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2025",
clientVersion: "5.0.0",
});
vi.mocked(prisma.environment.findUnique).mockRejectedValue(prismaError);
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow(DatabaseError);
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow("Database error");
});
test("should rethrow non-Prisma errors", async () => {
const mockEnvironmentId = "clh1a2b3c4d5e6f7g8h9r";
const genericError = new Error("Generic error");
vi.mocked(prisma.environment.findUnique).mockRejectedValue(genericError);
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow(genericError);
});
test("should handle project with minimal data", async () => {
const mockEnvironmentId = "clh1a2b3c4d5e6f7g8h9s";
const mockData = {
project: {
id: "clh1a2b3c4d5e6f7g8h9t",
name: "Minimal Project",
styling: null,
logo: null,
linkSurveyBranding: false,
organizationId: "clh1a2b3c4d5e6f7g8h9u",
organization: {
id: "clh1a2b3c4d5e6f7g8h9u",
billing: {
plan: "free",
limits: {
monthly: {
responses: 100,
miu: 1000,
},
},
features: {
inAppSurvey: {
status: "inactive",
},
linkSurvey: {
status: "inactive",
},
},
},
},
},
};
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockData as any);
const result = await getEnvironmentContextForLinkSurvey(mockEnvironmentId);
expect(result).toEqual({
project: {
id: "clh1a2b3c4d5e6f7g8h9t",
name: "Minimal Project",
styling: null,
logo: null,
linkSurveyBranding: false,
},
organizationId: "clh1a2b3c4d5e6f7g8h9u",
organizationBilling: mockData.project.organization.billing,
});
});
});

View File

@@ -0,0 +1,103 @@
import "server-only";
import { Prisma, Project } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { validateInputs } from "@/lib/utils/validate";
/**
* @file Data access layer for link surveys - optimized environment context fetching
* @module modules/survey/link/lib/environment
*
* This module provides optimized data fetching for link survey rendering by combining
* related queries into a single database call. Uses React cache for automatic request
* deduplication within the same render cycle.
*/
type TProjectForLinkSurvey = Pick<Project, "id" | "name" | "styling" | "logo" | "linkSurveyBranding">;
export interface TEnvironmentContextForLinkSurvey {
project: TProjectForLinkSurvey;
organizationId: string;
organizationBilling: TOrganizationBilling;
}
/**
* Fetches all environment-related data needed for link surveys in a single optimized query.
* Combines project, organization, and billing data using Prisma relationships to minimize
* database round trips.
*
* This function is specifically optimized for link survey rendering and only fetches the
* fields required for that use case. Other parts of the application may need different
* field combinations and should use their own specialized functions.
*
* @param environmentId - The environment identifier
* @returns Object containing project styling data, organization ID, and billing information
* @throws ResourceNotFoundError if environment, project, or organization not found
* @throws DatabaseError if database query fails
*
* @example
* ```typescript
* // In server components, function is automatically cached per request
* const { project, organizationId, organizationBilling } =
* await getEnvironmentContextForLinkSurvey(survey.environmentId);
* ```
*/
export const getEnvironmentContextForLinkSurvey = reactCache(
async (environmentId: string): Promise<TEnvironmentContextForLinkSurvey> => {
validateInputs([environmentId, ZId]);
try {
const environment = await prisma.environment.findUnique({
where: { id: environmentId },
select: {
project: {
select: {
id: true,
name: true,
styling: true,
logo: true,
linkSurveyBranding: true,
organizationId: true,
organization: {
select: {
id: true,
billing: true,
},
},
},
},
},
});
// Fail early pattern: validate data before proceeding
if (!environment?.project) {
throw new ResourceNotFoundError("Project", null);
}
if (!environment.project.organization) {
throw new ResourceNotFoundError("Organization", null);
}
// Return structured, typed data
return {
project: {
id: environment.project.id,
name: environment.project.name,
styling: environment.project.styling,
logo: environment.project.logo,
linkSurveyBranding: environment.project.linkSurveyBranding,
},
organizationId: environment.project.organizationId,
organizationBilling: environment.project.organization.billing as TOrganizationBilling,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);

View File

@@ -19,16 +19,21 @@ export const getNameForURL = (value: string) => encodeURIComponent(value);
export const getBrandColorForURL = (value: string) => encodeURIComponent(value);
/**
* Get basic survey metadata (title and description) based on link metadata, welcome card or survey name
* Get basic survey metadata (title and description) based on link metadata, welcome card or survey name.
*
* @param surveyId - Survey identifier
* @param languageCode - Language code for localization (default: "default")
* @param survey - Optional survey data if already available (e.g., from generateMetadata)
*/
export const getBasicSurveyMetadata = async (
surveyId: string,
languageCode = "default"
languageCode = "default",
survey?: Awaited<ReturnType<typeof getSurvey>> | null
): Promise<TBasicSurveyMetadata> => {
const survey = await getSurvey(surveyId);
const surveyData = survey ?? (await getSurvey(surveyId));
// If survey doesn't exist, return default metadata
if (!survey) {
if (!surveyData) {
return {
title: "Survey",
description: "Please complete this survey.",
@@ -37,11 +42,11 @@ export const getBasicSurveyMetadata = async (
};
}
const metadata = survey.metadata;
const welcomeCard = survey.welcomeCard;
const metadata = surveyData.metadata;
const welcomeCard = surveyData.welcomeCard;
const useDefaultLanguageCode =
languageCode === "default" ||
survey.languages.find((lang) => lang.language.code === languageCode)?.default;
surveyData.languages.find((lang) => lang.language.code === languageCode)?.default;
// Determine language code to use for metadata
const langCode = useDefaultLanguageCode ? "default" : languageCode;
@@ -51,10 +56,10 @@ export const getBasicSurveyMetadata = async (
const titleFromWelcome =
welcomeCard?.enabled && welcomeCard.headline
? getTextContent(
getLocalizedValue(recallToHeadline(welcomeCard.headline, survey, false, langCode), langCode)
getLocalizedValue(recallToHeadline(welcomeCard.headline, surveyData, false, langCode), langCode)
) || ""
: undefined;
let title = titleFromMetadata || titleFromWelcome || survey.name;
let title = titleFromMetadata || titleFromWelcome || surveyData.name;
// Set description - priority: custom link metadata > default
const descriptionFromMetadata = metadata?.description
@@ -63,7 +68,7 @@ export const getBasicSurveyMetadata = async (
let description = descriptionFromMetadata || "Please complete this survey.";
// Get OG image from link metadata if available
const { ogImage } = metadata;
const ogImage = metadata?.ogImage;
if (!titleFromMetadata) {
if (IS_FORMBRICKS_CLOUD) {
@@ -74,7 +79,7 @@ export const getBasicSurveyMetadata = async (
return {
title,
description,
survey,
survey: surveyData,
ogImage,
};
};

View File

@@ -1,11 +1,11 @@
import { notFound } from "next/navigation";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getSurveyMetadata } from "@/modules/survey/link/lib/data";
import { getSurveyWithMetadata } from "@/modules/survey/link/lib/data";
import { getBasicSurveyMetadata, getSurveyOpenGraphMetadata } from "./lib/metadata-utils";
import { getMetadataForLinkSurvey } from "./metadata";
vi.mock("@/modules/survey/link/lib/data", () => ({
getSurveyMetadata: vi.fn(),
getSurveyWithMetadata: vi.fn(),
}));
vi.mock("next/navigation", () => ({
@@ -54,12 +54,12 @@ describe("getMetadataForLinkSurvey", () => {
status: "published",
} as any;
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey);
const result = await getMetadataForLinkSurvey(mockSurveyId);
expect(getSurveyMetadata).toHaveBeenCalledWith(mockSurveyId);
expect(getBasicSurveyMetadata).toHaveBeenCalledWith(mockSurveyId, undefined);
expect(getSurveyWithMetadata).toHaveBeenCalledWith(mockSurveyId);
expect(getBasicSurveyMetadata).toHaveBeenCalledWith(mockSurveyId, undefined, mockSurvey);
expect(getSurveyOpenGraphMetadata).toHaveBeenCalledWith(mockSurveyId, mockSurveyName, undefined);
expect(result).toEqual({
@@ -98,7 +98,7 @@ describe("getMetadataForLinkSurvey", () => {
status: "published",
} as any;
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getBasicSurveyMetadata).mockResolvedValue({
title: mockSurveyName,
description: mockDescription,
@@ -120,7 +120,7 @@ describe("getMetadataForLinkSurvey", () => {
status: "published",
};
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey as any);
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey as any);
await getMetadataForLinkSurvey(mockSurveyId);
@@ -135,7 +135,7 @@ describe("getMetadataForLinkSurvey", () => {
status: "draft",
} as any;
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey);
await getMetadataForLinkSurvey(mockSurveyId);
@@ -150,7 +150,7 @@ describe("getMetadataForLinkSurvey", () => {
status: "published",
} as any;
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getSurveyOpenGraphMetadata).mockReturnValue({
twitter: {
title: mockSurveyName,
@@ -192,7 +192,7 @@ describe("getMetadataForLinkSurvey", () => {
status: "published",
} as any;
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getSurveyOpenGraphMetadata).mockReturnValue({
openGraph: {
title: mockSurveyName,

View File

@@ -1,20 +1,19 @@
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { getSurveyMetadata } from "@/modules/survey/link/lib/data";
import { getSurveyWithMetadata } from "@/modules/survey/link/lib/data";
import { getBasicSurveyMetadata, getSurveyOpenGraphMetadata } from "./lib/metadata-utils";
export const getMetadataForLinkSurvey = async (
surveyId: string,
languageCode?: string
): Promise<Metadata> => {
const survey = await getSurveyMetadata(surveyId);
const survey = await getSurveyWithMetadata(surveyId);
if (!survey || survey.type !== "link" || survey.status === "draft") {
if (!survey || survey?.type !== "link" || survey?.status === "draft") {
notFound();
}
// Get enhanced metadata that includes custom link metadata
const { title, description, ogImage } = await getBasicSurveyMetadata(surveyId, languageCode);
const { title, description, ogImage } = await getBasicSurveyMetadata(surveyId, languageCode, survey);
const surveyBrandColor = survey.styling?.brandColor?.light;
// Use the shared function for creating the base metadata but override with custom data

View File

@@ -3,11 +3,14 @@ import { notFound } from "next/navigation";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { TSurvey } from "@formbricks/types/surveys/types";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
import { getResponseBySingleUseId, getSurveyWithMetadata } from "@/modules/survey/link/lib/data";
import { getEnvironmentContextForLinkSurvey } from "@/modules/survey/link/lib/environment";
import { checkAndValidateSingleUseId } from "@/modules/survey/link/lib/helper";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata";
interface LinkSurveyPageProps {
@@ -47,7 +50,29 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
const isPreview = searchParams.preview === "true";
// Use optimized survey data fetcher (includes all necessary data)
/**
* Optimized data fetching strategy for link surveys
*
* PERFORMANCE OPTIMIZATION:
* We fetch data in carefully staged parallel operations to minimize latency.
* Each sequential database call adds ~100-300ms for users far from servers.
*
* Fetch stages:
* Stage 1: Survey (required first - provides config for all other fetches)
* Stage 2: Parallel fetch of environment context, locale, and conditional single-use response
* Stage 3: Multi-language permission (depends on billing from Stage 2)
*
* This reduces waterfall from 4-5 levels to 3 levels:
* - Before: ~400-1500ms added latency for distant users
* - After: ~200-600ms added latency for distant users
* - Improvement: 50-60% latency reduction
*
* CACHING NOTE:
* getSurveyWithMetadata is wrapped in React's cache(), so the call from
* generateMetadata and this page component are automatically deduplicated.
*/
// Stage 1: Fetch survey first (required for all subsequent logic)
let survey: TSurvey | null = null;
try {
survey = await getSurveyWithMetadata(params.surveyId);
@@ -56,40 +81,60 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
return notFound();
}
if (!survey) {
return notFound();
}
const suId = searchParams.suId;
const isSingleUseSurvey = survey?.singleUse?.enabled;
const isSingleUseSurveyEncrypted = survey?.singleUse?.isEncrypted;
// Validate single-use ID early (no I/O, just validation)
const isSingleUseSurvey = survey.singleUse?.enabled;
const isSingleUseSurveyEncrypted = survey.singleUse?.isEncrypted;
let singleUseId: string | undefined = undefined;
if (isSingleUseSurvey) {
const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted);
if (!validatedSingleUseId) {
const project = await getProjectByEnvironmentId(survey.environmentId);
return <SurveyInactive status="link invalid" project={project ?? undefined} />;
// Need to fetch project for error page - fetch environmentContext for it
const environmentContext = await getEnvironmentContextForLinkSurvey(survey.environmentId);
return <SurveyInactive status="link invalid" project={environmentContext.project} />;
}
singleUseId = validatedSingleUseId;
}
let singleUseResponse;
if (isSingleUseSurvey && singleUseId) {
try {
// Use optimized response fetcher with proper caching
const fetchResponseFn = getResponseBySingleUseId(survey.id, singleUseId);
singleUseResponse = await fetchResponseFn();
} catch (error) {
logger.error("Error fetching single use response:", error);
singleUseResponse = undefined;
}
}
// Stage 2: Parallel fetch of all remaining data
const [environmentContext, locale, singleUseResponse] = await Promise.all([
getEnvironmentContextForLinkSurvey(survey.environmentId),
findMatchingLocale(),
// Only fetch single-use response if we have a validated ID
isSingleUseSurvey && singleUseId
? getResponseBySingleUseId(survey.id, singleUseId)()
: Promise.resolve(undefined),
]);
// Stage 3: Get multi-language permission (depends on environmentContext)
// Future optimization: Consider caching getMultiLanguagePermission by plan tier
// since it's a pure computation based on billing plan. Could be memoized at
// the plan level rather than per-request.
const isMultiLanguageAllowed = await getMultiLanguagePermission(
environmentContext.organizationBilling.plan
);
// Fetch responseCount only if needed (depends on survey config)
const responseCount = survey.welcomeCard.showResponseCount
? await getResponseCountBySurveyId(survey.id)
: undefined;
// Pass all pre-fetched data to renderer
return renderSurvey({
survey,
searchParams,
singleUseId,
singleUseResponse,
singleUseResponse: singleUseResponse ?? undefined,
isPreview,
environmentContext,
locale,
isMultiLanguageAllowed,
responseCount,
});
};

View File

@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { convertDateString, timeSince } from "@/lib/time";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
import { SurveyTypeIndicator } from "@/modules/survey/list/components/survey-type-indicator";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
@@ -48,8 +47,6 @@ export const SurveyCard = ({
const isSurveyCreationDeletionDisabled = isReadOnly;
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const linkHref = useMemo(() => {
return survey.status === "draft"
? `/environments/${environmentId}/surveys/${survey.id}/edit`
@@ -101,7 +98,6 @@ export const SurveyCard = ({
environmentId={environmentId}
publicDomain={publicDomain}
disabled={isDraftAndReadOnly}
refreshSingleUseId={refreshSingleUseId}
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
deleteSurvey={deleteSurvey}
onSurveysCopied={onSurveysCopied}

View File

@@ -11,7 +11,7 @@ import {
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
@@ -39,7 +39,6 @@ interface SurveyDropDownMenuProps {
environmentId: string;
survey: TSurvey;
publicDomain: string;
refreshSingleUseId: () => Promise<string | undefined>;
disabled?: boolean;
isSurveyCreationDeletionDisabled?: boolean;
deleteSurvey: (surveyId: string) => void;
@@ -50,7 +49,6 @@ export const SurveyDropDownMenu = ({
environmentId,
survey,
publicDomain,
refreshSingleUseId,
disabled,
isSurveyCreationDeletionDisabled,
deleteSurvey,
@@ -62,26 +60,11 @@ export const SurveyDropDownMenu = ({
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const [isCopyFormOpen, setIsCopyFormOpen] = useState(false);
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
const [newSingleUseId, setNewSingleUseId] = useState<string | undefined>(undefined);
const router = useRouter();
const surveyLink = useMemo(() => publicDomain + "/s/" + survey.id, [survey.id, publicDomain]);
// Pre-fetch single-use ID when dropdown opens to avoid async delay during clipboard operation
// This ensures Safari's clipboard API works by maintaining the user gesture context
useEffect(() => {
if (!isDropDownOpen) return;
const fetchNewId = async () => {
try {
const newId = await refreshSingleUseId();
setNewSingleUseId(newId ?? undefined);
} catch (error) {
logger.error(error);
}
};
fetchNewId();
}, [refreshSingleUseId, isDropDownOpen]);
const isSingleUseEnabled = survey.singleUse?.enabled ?? false;
const handleDeleteSurvey = async (surveyId: string) => {
setLoading(true);
@@ -100,7 +83,8 @@ export const SurveyDropDownMenu = ({
try {
e.preventDefault();
setIsDropDownOpen(false);
const copiedLink = copySurveyLink(surveyLink, newSingleUseId);
// For single-use surveys, this button is disabled, so we just copy the base link
const copiedLink = copySurveyLink(surveyLink);
navigator.clipboard.writeText(copiedLink);
toast.success(t("common.copied_to_clipboard"));
} catch (error) {
@@ -205,31 +189,36 @@ export const SurveyDropDownMenu = ({
<>
<DropdownMenuItem>
<button
className="flex w-full cursor-pointer items-center"
type="button"
className={cn(
"flex w-full items-center",
isSingleUseEnabled && "cursor-not-allowed opacity-50"
)}
disabled={isSingleUseEnabled}
onClick={async (e) => {
e.preventDefault();
setIsDropDownOpen(false);
const newId = await refreshSingleUseId();
const previewUrl =
surveyLink + (newId ? `?suId=${newId}&preview=true` : "?preview=true");
const previewUrl = surveyLink + "?preview=true";
window.open(previewUrl, "_blank");
}}>
<EyeIcon className="mr-2 h-4 w-4" />
{t("common.preview_survey")}
</button>
</DropdownMenuItem>
{!survey.singleUse?.enabled && (
<DropdownMenuItem>
<button
type="button"
data-testid="copy-link"
className="flex w-full items-center"
onClick={async (e) => handleCopyLink(e)}>
<LinkIcon className="mr-2 h-4 w-4" />
{t("common.copy_link")}
</button>
</DropdownMenuItem>
)}
<DropdownMenuItem>
<button
type="button"
data-testid="copy-link"
className={cn(
"flex w-full items-center",
isSingleUseEnabled && "cursor-not-allowed opacity-50"
)}
disabled={isSingleUseEnabled}
onClick={async (e) => handleCopyLink(e)}>
<LinkIcon className="mr-2 h-4 w-4" />
{t("common.copy_link")}
</button>
</DropdownMenuItem>
</>
)}
{!isSurveyCreationDeletionDisabled && (

View File

@@ -7,8 +7,9 @@ import { useTranslation } from "react-i18next";
import { useDebounce } from "react-use";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { TFilterOption, TSortOption, TSurveyFilters } from "@formbricks/types/surveys/types";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { SortOption } from "@/modules/survey/list/components/sort-option";
import { initialFilters } from "@/modules/survey/list/components/survey-list";
import { initialFilters } from "@/modules/survey/list/lib/constants";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
@@ -154,12 +155,13 @@ export const SurveyFilters = ({
</div>
)}
{(createdBy.length > 0 || status.length > 0 || type.length > 0) && (
{(createdBy.length > 0 || status.length > 0 || type.length > 0 || name) && (
<Button
size="sm"
onClick={() => {
setSurveyFilters(initialFilters);
localStorage.removeItem("surveyFilters");
setName(""); // Also clear the search input
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
}}
className="h-8">
{t("common.clear_filters")}

View File

@@ -10,6 +10,7 @@ import { TSurveyFilters } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { getSurveysAction } from "@/modules/survey/list/actions";
import { initialFilters } from "@/modules/survey/list/lib/constants";
import { getFormattedFilters } from "@/modules/survey/list/lib/utils";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { Button } from "@/modules/ui/components/button";
@@ -27,14 +28,6 @@ interface SurveysListProps {
locale: TUserLocale;
}
export const initialFilters: TSurveyFilters = {
name: "",
createdBy: [],
status: [],
type: [],
sortBy: "relevance",
};
export const SurveysList = ({
environmentId,
isReadOnly,
@@ -46,14 +39,18 @@ export const SurveysList = ({
}: SurveysListProps) => {
const router = useRouter();
const [surveys, setSurveys] = useState<TSurvey[]>([]);
const [isFetching, setIsFetching] = useState(true);
const [hasMore, setHasMore] = useState<boolean>(true);
const [isFetching, setIsFetching] = useState(false);
const [hasMore, setHasMore] = useState<boolean>(false);
const [refreshTrigger, setRefreshTrigger] = useState(false);
const { t } = useTranslation();
const [surveyFilters, setSurveyFilters] = useState<TSurveyFilters>(initialFilters);
const [isFilterInitialized, setIsFilterInitialized] = useState(false);
const filters = useMemo(() => getFormattedFilters(surveyFilters, userId), [surveyFilters, userId]);
const { name, createdBy, status, type, sortBy } = surveyFilters;
const filters = useMemo(
() => getFormattedFilters(surveyFilters, userId),
[name, JSON.stringify(createdBy), JSON.stringify(status), JSON.stringify(type), sortBy, userId]
);
const [parent] = useAutoAnimate();
useEffect(() => {
@@ -80,28 +77,30 @@ export const SurveysList = ({
}, [surveyFilters, isFilterInitialized]);
useEffect(() => {
if (isFilterInitialized) {
const fetchInitialSurveys = async () => {
setIsFetching(true);
const res = await getSurveysAction({
environmentId,
limit: surveysLimit,
offset: undefined,
filterCriteria: filters,
});
if (res?.data) {
if (res.data.length < surveysLimit) {
setHasMore(false);
} else {
setHasMore(true);
}
setSurveys(res.data);
setIsFetching(false);
// Wait for filters to be loaded from localStorage before fetching
if (!isFilterInitialized) return;
const fetchFilteredSurveys = async () => {
setIsFetching(true);
const res = await getSurveysAction({
environmentId,
limit: surveysLimit,
offset: undefined,
filterCriteria: filters,
});
if (res?.data) {
if (res.data.length < surveysLimit) {
setHasMore(false);
} else {
setHasMore(true);
}
};
fetchInitialSurveys();
}
}, [environmentId, surveysLimit, filters, isFilterInitialized, refreshTrigger]);
setSurveys(res.data);
setIsFetching(false);
}
};
fetchFilteredSurveys();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [environmentId, surveysLimit, filters, refreshTrigger, isFilterInitialized]);
const fetchNextPage = useCallback(async () => {
setIsFetching(true);

View File

@@ -0,0 +1,9 @@
import { TSurveyFilters } from "@formbricks/types/surveys/types";
export const initialFilters: TSurveyFilters = {
name: "",
createdBy: [],
status: [],
type: [],
sortBy: "relevance",
};

View File

@@ -1,8 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
import { executeRecaptcha, loadRecaptchaScript } from "@/modules/ui/components/survey/recaptcha";
const createContainerId = () => `formbricks-survey-container`;
// Module-level flag to prevent concurrent script loads across component instances
let isLoadingScript = false;
declare global {
interface Window {
formbricksSurveys: {
@@ -10,6 +14,7 @@ declare global {
renderSurveyModal: (props: SurveyContainerProps) => void;
renderSurvey: (props: SurveyContainerProps) => void;
onFilePick: (files: { name: string; type: string; base64: string }[]) => void;
setNonce: (nonce: string | undefined) => void;
};
}
}
@@ -26,8 +31,11 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
[containerId, props, getRecaptchaToken]
);
const [isScriptLoaded, setIsScriptLoaded] = useState(false);
const hasLoadedRef = useRef(false);
const loadSurveyScript: () => Promise<void> = async () => {
// Set loading flag immediately to prevent concurrent loads
isLoadingScript = true;
try {
const response = await fetch("/js/surveys.umd.cjs");
@@ -42,12 +50,20 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
document.head.appendChild(scriptElement);
setIsScriptLoaded(true);
hasLoadedRef.current = true;
} catch (error) {
throw error;
} finally {
isLoadingScript = false;
}
};
useEffect(() => {
// Prevent duplicate loads across multiple renders or component instances
if (hasLoadedRef.current || isLoadingScript) {
return;
}
const loadScript = async () => {
if (!window.formbricksSurveys) {
try {
@@ -64,7 +80,8 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
};
loadScript();
}, [containerId, props, renderInline]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props]);
useEffect(() => {
if (isScriptLoaded) {

View File

@@ -110,6 +110,10 @@ export const ThemeStylingPreviewSurvey = ({
const isAppSurvey = previewType === "app";
// Create a unique key that includes both timestamp and preview type
// This ensures the survey remounts when switching between app and link
const surveyKey = `${previewType}-${surveyFormKey}`;
const scrollToEditLogoSection = () => {
const editLogoSection = document.getElementById("edit-logo");
if (editLogoSection) {
@@ -160,7 +164,7 @@ export const ThemeStylingPreviewSurvey = ({
previewMode="desktop"
background={project.styling.cardBackgroundColor?.light}
borderRadius={project.styling.roundness ?? 8}>
<Fragment key={surveyFormKey}>
<Fragment key={surveyKey}>
<SurveyInline
isPreviewMode={true}
survey={{ ...survey, type: "app" }}
@@ -185,7 +189,7 @@ export const ThemeStylingPreviewSurvey = ({
</button>
)}
<div
key={surveyFormKey}
key={surveyKey}
className={`${project.logo?.url && !project.styling.isLogoHidden && !isFullScreenPreview ? "mt-12" : ""} z-0 w-full max-w-md rounded-lg p-4`}>
<SurveyInline
isPreviewMode={true}

View File

@@ -76,6 +76,19 @@ const registerRouteChange = async (): Promise<void> => {
await queue.add(checkPageUrl, CommandType.GeneralAction);
};
/**
* Set the CSP nonce for inline styles
* @param nonce - The CSP nonce value (without 'nonce-' prefix), or undefined to clear
*/
const setNonce = (nonce: string | undefined): void => {
// Store nonce on window for access when surveys package loads
globalThis.window.__formbricksNonce = nonce;
// Set nonce in surveys package if it's already loaded
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
globalThis.window.formbricksSurveys?.setNonce?.(nonce);
};
const formbricks = {
/** @deprecated Use setup() instead. This method will be removed in a future version */
init: (initConfig: TLegacyConfigInput) => setup(initConfig as unknown as TConfigInput),
@@ -88,6 +101,7 @@ const formbricks = {
track,
logout,
registerRouteChange,
setNonce,
};
type TFormbricks = typeof formbricks;

View File

@@ -201,19 +201,24 @@ export const removeWidgetContainer = (): void => {
document.getElementById(CONTAINER_ID)?.remove();
};
const loadFormbricksSurveysExternally = (): Promise<typeof window.formbricksSurveys> => {
const loadFormbricksSurveysExternally = (): Promise<typeof globalThis.window.formbricksSurveys> => {
const config = Config.getInstance();
return new Promise((resolve, reject) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- We need to check if the formbricksSurveys object exists
if (window.formbricksSurveys) {
resolve(window.formbricksSurveys);
if (globalThis.window.formbricksSurveys) {
resolve(globalThis.window.formbricksSurveys);
} else {
const script = document.createElement("script");
script.src = `${config.get().appUrl}/js/surveys.umd.cjs`;
script.async = true;
script.onload = () => {
resolve(window.formbricksSurveys);
// Apply stored nonce if it was set before surveys package loaded
const storedNonce = globalThis.window.__formbricksNonce;
if (storedNonce) {
globalThis.window.formbricksSurveys.setNonce(storedNonce);
}
resolve(globalThis.window.formbricksSurveys);
};
script.onerror = (error) => {
console.error("Failed to load Formbricks Surveys library:", error);

View File

@@ -1,7 +1,7 @@
import DOMPurify from "isomorphic-dompurify";
import { useTranslation } from "react-i18next";
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { isValidHTML } from "@/lib/html-utils";
import { isValidHTML, stripInlineStyles } from "@/lib/html-utils";
interface HeadlineProps {
headline: string;
@@ -12,8 +12,16 @@ interface HeadlineProps {
export function Headline({ headline, questionId, required = true, alignTextCenter = false }: HeadlineProps) {
const { t } = useTranslation();
const isHeadlineHtml = isValidHTML(headline);
const safeHtml = isHeadlineHtml && headline ? DOMPurify.sanitize(headline, { ADD_ATTR: ["target"] }) : "";
// Strip inline styles BEFORE parsing to avoid CSP violations
const strippedHeadline = stripInlineStyles(headline);
const isHeadlineHtml = isValidHTML(strippedHeadline);
const safeHtml =
isHeadlineHtml && strippedHeadline
? DOMPurify.sanitize(strippedHeadline, {
ADD_ATTR: ["target"],
FORBID_ATTR: ["style"], // Additional safeguard to remove any remaining inline styles
})
: "";
return (
<label htmlFor={questionId} className="fb-text-heading fb-mb-[3px] fb-flex fb-flex-col">

View File

@@ -1,6 +1,6 @@
import DOMPurify from "isomorphic-dompurify";
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { isValidHTML } from "@/lib/html-utils";
import { isValidHTML, stripInlineStyles } from "@/lib/html-utils";
interface SubheaderProps {
subheader?: string;
@@ -8,8 +8,16 @@ interface SubheaderProps {
}
export function Subheader({ subheader, questionId }: SubheaderProps) {
const isHtml = subheader ? isValidHTML(subheader) : false;
const safeHtml = isHtml && subheader ? DOMPurify.sanitize(subheader, { ADD_ATTR: ["target"] }) : "";
// Strip inline styles BEFORE parsing to avoid CSP violations
const strippedSubheader = subheader ? stripInlineStyles(subheader) : "";
const isHtml = strippedSubheader ? isValidHTML(strippedSubheader) : false;
const safeHtml =
isHtml && strippedSubheader
? DOMPurify.sanitize(strippedSubheader, {
ADD_ATTR: ["target"],
FORBID_ATTR: ["style"], // Additional safeguard to remove any remaining inline styles
})
: "";
if (!subheader) return null;

View File

@@ -203,8 +203,9 @@ export function Survey({
const getShowSurveyCloseButton = (offset: number) => {
return offset === 0 && localSurvey.type !== "link";
};
const enabledLanguages = localSurvey.languages.filter((lang) => lang.enabled);
const getShowLanguageSwitch = (offset: number) => {
return localSurvey.showLanguageSwitch && localSurvey.languages.length > 0 && offset <= 0;
return localSurvey.showLanguageSwitch && enabledLanguages.length > 1 && offset <= 0;
};
const onFileUpload = async (file: TJsFileUploadParams["file"], params?: TUploadFileConfig) => {

View File

@@ -0,0 +1,60 @@
import { type VariantProps, cva } from "class-variance-authority";
import { type JSX } from "preact";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
interface ButtonProps extends JSX.HTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
isLoading?: boolean;
disabled?: boolean;
style?: JSX.CSSProperties;
}
function Button({
className,
variant,
size,
onClick,
children,
isLoading,
disabled,
style,
...props
}: ButtonProps) {
return (
<button
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
onClick={onClick}
disabled={isLoading || disabled}
style={style}
{...props}>
{children}
</button>
);
}
export { Button, buttonVariants, type ButtonProps };

View File

@@ -0,0 +1,210 @@
import { Meta, StoryObj } from "@storybook/react-vite";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import { type JSX } from "preact";
import { fn } from "storybook/test";
import { Button, type ButtonProps } from "./button";
const meta: Meta<ButtonProps> = {
title: "Surveys/V5/Button",
component: Button as any,
tags: ["autodocs"],
parameters: {
layout: "centered",
controls: { sort: "alpha", exclude: [] },
docs: {
description: {
component:
"The **Button** component for survey interfaces provides clickable actions with multiple variants and sizes. Built with Preact for optimal performance in embedded survey widgets.",
},
},
},
argTypes: {
onClick: {
action: "clicked",
description: "Click handler function",
table: {
category: "Behavior",
type: { summary: "function" },
},
order: 1,
},
variant: {
control: "select",
options: ["default", "destructive", "outline", "secondary", "ghost", "link"],
description: "Visual style variant of the button",
table: {
category: "Appearance",
type: { summary: "string" },
defaultValue: { summary: "default" },
},
order: 1,
},
style: {
control: "object",
description: "Inline style object for custom CSS styling",
table: {
category: "Appearance",
type: { summary: "object" },
},
order: 4,
},
},
args: { onClick: fn() },
};
export default meta;
type Story = StoryObj<ButtonProps>;
export const Default: Story = {
args: {
children: "Submit Response",
variant: "default",
},
};
export const Destructive: Story = {
args: {
children: "Skip Survey",
variant: "destructive",
},
parameters: {
docs: {
description: {
story:
"Use for actions that are destructive or exit flows, like skipping a survey or canceling progress.",
},
},
},
};
export const Outline: Story = {
args: {
children: "Back",
variant: "outline",
},
parameters: {
docs: {
description: {
story:
"Use for secondary actions like navigation or when you need a button with less visual weight than the primary action.",
},
},
},
};
export const Secondary: Story = {
args: {
children: "Save Draft",
variant: "secondary",
},
parameters: {
docs: {
description: {
story: "Use for secondary actions that are less important than the primary submit action.",
},
},
},
};
export const Ghost: Story = {
args: {
children: "Skip Question",
variant: "ghost",
},
parameters: {
docs: {
description: {
story: "Use for subtle actions or when you need minimal visual impact in the survey flow.",
},
},
},
};
export const Link: Story = {
args: {
children: "Learn more",
variant: "link",
},
parameters: {
docs: {
description: {
story:
"Use when you want button functionality but link appearance, like for help text or additional information.",
},
},
},
};
export const Icon: Story = {
args: {
children: "→",
},
parameters: {
docs: {
description: {
story: "Square button for icon-only actions. Default size for icon buttons.",
},
},
},
};
export const textWithIconOnRight: Story = {
args: {
children: (
<div className="flex items-center gap-2">
<span>Next</span>
<ArrowRightIcon className="size-4" />
</div>
),
},
};
export const textWithIconOnLeft: Story = {
args: {
children: (
<div className="flex items-center gap-2">
<ArrowLeftIcon className="size-4" />
<span>Previous</span>
</div>
),
variant: "secondary",
},
};
export const Disabled: Story = {
args: {
children: "Submit",
disabled: true,
},
parameters: {
docs: {
description: {
story: "Use when the button action is temporarily unavailable, such as when survey validation fails.",
},
},
},
};
export const InlineStyleWithGradient: Story = {
args: {
children: "Gradient Button",
style: {
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "white",
padding: "12px 32px",
fontSize: "16px",
fontWeight: "600",
border: "none",
cursor: "pointer",
boxShadow: "0 8px 15px rgba(102, 126, 234, 0.4)",
} as JSX.CSSProperties,
},
parameters: {
docs: {
description: {
story:
"Inline styles can include complex CSS like gradients, perfect for creating visually striking buttons with custom theming.",
},
},
},
};

View File

@@ -4,7 +4,7 @@ import { RenderSurvey } from "@/components/general/render-survey";
import { I18nProvider } from "@/components/i18n/provider";
import { FILE_PICK_EVENT } from "@/lib/constants";
import { getI18nLanguage } from "@/lib/i18n-utils";
import { addCustomThemeToDom, addStylesToDom } from "@/lib/styles";
import { addCustomThemeToDom, addStylesToDom, setStyleNonce } from "@/lib/styles";
export const renderSurveyInline = (props: SurveyContainerProps) => {
const inlineProps: SurveyContainerProps = {
@@ -70,15 +70,17 @@ export const renderSurveyModal = renderSurvey;
export const onFilePick = (files: { name: string; type: string; base64: string }[]) => {
const fileUploadEvent = new CustomEvent(FILE_PICK_EVENT, { detail: files });
window.dispatchEvent(fileUploadEvent);
globalThis.dispatchEvent(fileUploadEvent);
};
// Initialize the global formbricksSurveys object if it doesn't exist
if (typeof window !== "undefined") {
window.formbricksSurveys = {
if (globalThis.window !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Type definition is in @formbricks/types package
(globalThis.window as any).formbricksSurveys = {
renderSurveyInline,
renderSurveyModal,
renderSurvey,
onFilePick,
};
setNonce: setStyleNonce,
} as typeof globalThis.window.formbricksSurveys;
}

View File

@@ -1,7 +1,48 @@
import { describe, expect, test } from "vitest";
import { isValidHTML } from "./html-utils";
import { isValidHTML, stripInlineStyles } from "./html-utils";
describe("html-utils", () => {
describe("stripInlineStyles", () => {
test("should remove inline styles with double quotes", () => {
const input = '<div style="color: red;">Test</div>';
const expected = "<div>Test</div>";
expect(stripInlineStyles(input)).toBe(expected);
});
test("should remove inline styles with single quotes", () => {
const input = "<div style='color: red;'>Test</div>";
const expected = "<div>Test</div>";
expect(stripInlineStyles(input)).toBe(expected);
});
test("should remove multiple inline styles", () => {
const input = '<div style="color: red;"><span style="font-size: 14px;">Test</span></div>';
const expected = "<div><span>Test</span></div>";
expect(stripInlineStyles(input)).toBe(expected);
});
test("should handle complex inline styles", () => {
const input = '<p style="margin: 10px; padding: 5px; background-color: blue;">Content</p>';
const expected = "<p>Content</p>";
expect(stripInlineStyles(input)).toBe(expected);
});
test("should not affect other attributes", () => {
const input = '<div class="test" id="myDiv" style="color: red;">Test</div>';
const expected = '<div class="test" id="myDiv">Test</div>';
expect(stripInlineStyles(input)).toBe(expected);
});
test("should return unchanged string if no inline styles", () => {
const input = '<div class="test">Test</div>';
expect(stripInlineStyles(input)).toBe(input);
});
test("should handle empty string", () => {
expect(stripInlineStyles("")).toBe("");
});
});
describe("isValidHTML", () => {
test("should return false for empty string", () => {
expect(isValidHTML("")).toBe(false);
@@ -22,5 +63,9 @@ describe("html-utils", () => {
test("should return true for complex HTML", () => {
expect(isValidHTML('<div class="test"><p>Test</p></div>')).toBe(true);
});
test("should handle HTML with inline styles (they should be stripped)", () => {
expect(isValidHTML('<p style="color: red;">Test</p>')).toBe(true);
});
});
});

View File

@@ -1,9 +1,23 @@
/**
* Strip inline style attributes from HTML string to avoid CSP violations
* @param html - The HTML string to process
* @returns HTML string with all style attributes removed
* @note This is a security measure to prevent CSP violations during HTML parsing
*/
export const stripInlineStyles = (html: string): string => {
// Remove style="..." or style='...' attributes
// Use separate patterns for each quote type to avoid ReDoS vulnerability
// The pattern [^"]* and [^']* are safe as they don't cause backtracking
return html.replace(/\s+style\s*=\s*["'][^"']*["']/gi, ""); //NOSONAR
};
/**
* Lightweight HTML detection for browser environments
* Uses native DOMParser (built-in, 0 KB bundle size)
* @param str - The input string to test
* @returns true if the string contains valid HTML elements, false otherwise
* @note Returns false in non-browser environments (SSR, Node.js) where window is undefined
* @note Strips inline styles before parsing to avoid CSP violations
*/
export const isValidHTML = (str: string): boolean => {
// This should ideally never happen because the surveys package should be used in an environment where DOM is available
@@ -12,7 +26,10 @@ export const isValidHTML = (str: string): boolean => {
if (!str) return false;
try {
const doc = new DOMParser().parseFromString(str, "text/html");
// Strip inline style attributes to avoid CSP violations during parsing
const strippedStr = stripInlineStyles(str);
const doc = new DOMParser().parseFromString(strippedStr, "text/html");
const errorNode = doc.querySelector("parsererror");
if (errorNode) return false;
return Array.from(doc.body.childNodes).some((node) => node.nodeType === 1);

View File

@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { type TProjectStyling } from "@formbricks/types/project";
import { type TSurveyStyling } from "@formbricks/types/surveys/types";
import { addCustomThemeToDom, addStylesToDom } from "./styles";
import { addCustomThemeToDom, addStylesToDom, getStyleNonce, setStyleNonce } from "./styles";
// Mock CSS module imports
vi.mock("@/styles/global.css?inline", () => ({ default: ".global {}" }));
@@ -40,11 +40,85 @@ const getBaseProjectStyling = (overrides: Partial<TProjectStyling> = {}): TProje
};
};
describe("setStyleNonce and getStyleNonce", () => {
beforeEach(() => {
// Reset the DOM and nonce before each test
document.head.innerHTML = "";
document.body.innerHTML = "";
setStyleNonce(undefined);
});
test("should set and get the nonce value", () => {
const nonce = "test-nonce-123";
setStyleNonce(nonce);
expect(getStyleNonce()).toBe(nonce);
});
test("should allow clearing the nonce with undefined", () => {
setStyleNonce("initial-nonce");
expect(getStyleNonce()).toBe("initial-nonce");
setStyleNonce(undefined);
expect(getStyleNonce()).toBeUndefined();
});
test("should update existing formbricks__css element with nonce", () => {
// Create an existing style element
const existingElement = document.createElement("style");
existingElement.id = "formbricks__css";
document.head.appendChild(existingElement);
const nonce = "test-nonce-456";
setStyleNonce(nonce);
expect(existingElement.getAttribute("nonce")).toBe(nonce);
});
test("should update existing formbricks__css__custom element with nonce", () => {
// Create an existing custom style element
const existingElement = document.createElement("style");
existingElement.id = "formbricks__css__custom";
document.head.appendChild(existingElement);
const nonce = "test-nonce-789";
setStyleNonce(nonce);
expect(existingElement.getAttribute("nonce")).toBe(nonce);
});
test("should not update nonce on existing elements when nonce is undefined", () => {
// Create existing style elements
const mainElement = document.createElement("style");
mainElement.id = "formbricks__css";
mainElement.setAttribute("nonce", "existing-nonce");
document.head.appendChild(mainElement);
const customElement = document.createElement("style");
customElement.id = "formbricks__css__custom";
customElement.setAttribute("nonce", "existing-nonce");
document.head.appendChild(customElement);
setStyleNonce(undefined);
// Elements should retain their existing nonce (or be cleared if implementation removes it)
// The current implementation doesn't remove nonce when undefined, so we check it's not changed
expect(mainElement.getAttribute("nonce")).toBe("existing-nonce");
expect(customElement.getAttribute("nonce")).toBe("existing-nonce");
});
test("should handle setting nonce when elements don't exist", () => {
const nonce = "test-nonce-no-elements";
setStyleNonce(nonce);
expect(getStyleNonce()).toBe(nonce);
// Should not throw and should store the nonce for future use
});
});
describe("addStylesToDom", () => {
beforeEach(() => {
// Reset the DOM before each test
document.head.innerHTML = "";
document.body.innerHTML = "";
setStyleNonce(undefined);
});
afterEach(() => {
@@ -52,6 +126,7 @@ describe("addStylesToDom", () => {
if (styleElement) {
styleElement.remove();
}
setStyleNonce(undefined);
});
test("should add a style element to the head with combined CSS", () => {
@@ -78,12 +153,68 @@ describe("addStylesToDom", () => {
expect(secondStyleElement).toBe(firstStyleElement);
expect(secondStyleElement?.innerHTML).toBe(initialInnerHTML);
});
test("should apply nonce to new style element when nonce is set", () => {
const nonce = "test-nonce-styles";
setStyleNonce(nonce);
addStylesToDom();
const styleElement = document.getElementById("formbricks__css") as HTMLStyleElement;
expect(styleElement).not.toBeNull();
expect(styleElement.getAttribute("nonce")).toBe(nonce);
});
test("should not apply nonce when nonce is not set", () => {
addStylesToDom();
const styleElement = document.getElementById("formbricks__css") as HTMLStyleElement;
expect(styleElement).not.toBeNull();
expect(styleElement.getAttribute("nonce")).toBeNull();
});
test("should update nonce on existing style element if nonce is set after creation", () => {
addStylesToDom(); // Create element without nonce
const styleElement = document.getElementById("formbricks__css") as HTMLStyleElement;
expect(styleElement.getAttribute("nonce")).toBeNull();
const nonce = "test-nonce-update";
setStyleNonce(nonce);
addStylesToDom(); // Call again to trigger update logic
expect(styleElement.getAttribute("nonce")).toBe(nonce);
});
test("should not overwrite existing nonce when updating via addStylesToDom", () => {
const existingElement = document.createElement("style");
existingElement.id = "formbricks__css";
existingElement.setAttribute("nonce", "existing-nonce");
document.head.appendChild(existingElement);
// Don't call setStyleNonce - just verify addStylesToDom doesn't overwrite
addStylesToDom(); // Should not overwrite since nonce already exists
// The update logic in addStylesToDom only sets nonce if it doesn't exist
expect(existingElement.getAttribute("nonce")).toBe("existing-nonce");
});
test("should overwrite existing nonce when setStyleNonce is called directly", () => {
const existingElement = document.createElement("style");
existingElement.id = "formbricks__css";
existingElement.setAttribute("nonce", "existing-nonce");
document.head.appendChild(existingElement);
const newNonce = "new-nonce";
setStyleNonce(newNonce); // setStyleNonce always updates existing elements
// setStyleNonce directly updates the nonce attribute
expect(existingElement.getAttribute("nonce")).toBe(newNonce);
});
});
describe("addCustomThemeToDom", () => {
beforeEach(() => {
document.head.innerHTML = "";
document.body.innerHTML = "";
setStyleNonce(undefined);
});
afterEach(() => {
@@ -91,6 +222,7 @@ describe("addCustomThemeToDom", () => {
if (styleElement) {
styleElement.remove();
}
setStyleNonce(undefined);
});
const getCssVariables = (styleElement: HTMLStyleElement | null): Record<string, string> => {
@@ -271,6 +403,66 @@ describe("addCustomThemeToDom", () => {
expect(variables["--fb-survey-background-color"]).toBeUndefined();
expect(variables["--fb-input-background-color"]).toBeUndefined();
});
test("should apply nonce to new custom theme style element when nonce is set", () => {
const nonce = "test-nonce-custom";
setStyleNonce(nonce);
const styling = getBaseProjectStyling({ brandColor: { light: "#FF0000" } });
addCustomThemeToDom({ styling });
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
expect(styleElement).not.toBeNull();
expect(styleElement.getAttribute("nonce")).toBe(nonce);
});
test("should not apply nonce when nonce is not set", () => {
const styling = getBaseProjectStyling({ brandColor: { light: "#FF0000" } });
addCustomThemeToDom({ styling });
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
expect(styleElement).not.toBeNull();
expect(styleElement.getAttribute("nonce")).toBeNull();
});
test("should update nonce on existing custom style element if nonce is set after creation", () => {
const styling = getBaseProjectStyling({ brandColor: { light: "#FF0000" } });
addCustomThemeToDom({ styling }); // Create element without nonce
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
expect(styleElement.getAttribute("nonce")).toBeNull();
const nonce = "test-nonce-custom-update";
setStyleNonce(nonce);
addCustomThemeToDom({ styling }); // Call again to trigger update logic
expect(styleElement.getAttribute("nonce")).toBe(nonce);
});
test("should not overwrite existing nonce when updating custom theme via addCustomThemeToDom", () => {
const existingElement = document.createElement("style");
existingElement.id = "formbricks__css__custom";
existingElement.setAttribute("nonce", "existing-custom-nonce");
document.head.appendChild(existingElement);
// Don't call setStyleNonce - just verify addCustomThemeToDom doesn't overwrite
const styling = getBaseProjectStyling({ brandColor: { light: "#FF0000" } });
addCustomThemeToDom({ styling }); // Should not overwrite since nonce already exists
// The update logic in addCustomThemeToDom only sets nonce if it doesn't exist
expect(existingElement.getAttribute("nonce")).toBe("existing-custom-nonce");
});
test("should overwrite existing nonce when setStyleNonce is called directly on custom theme", () => {
const existingElement = document.createElement("style");
existingElement.id = "formbricks__css__custom";
existingElement.setAttribute("nonce", "existing-custom-nonce");
document.head.appendChild(existingElement);
const newNonce = "new-custom-nonce";
setStyleNonce(newNonce); // setStyleNonce directly updates the nonce attribute
// setStyleNonce directly updates the nonce attribute
expect(existingElement.getAttribute("nonce")).toBe(newNonce);
});
});
describe("getBaseProjectStyling_Helper", () => {

View File

@@ -8,24 +8,74 @@ import preflight from "@/styles/preflight.css?inline";
import editorCss from "../../../../apps/web/modules/ui/components/editor/styles-editor-frontend.css?inline";
import datePickerCustomCss from "../styles/date-picker.css?inline";
// Store the nonce globally for style elements
let styleNonce: string | undefined;
/**
* Set the CSP nonce to be applied to all style elements
* @param nonce - The CSP nonce value (without 'nonce-' prefix)
*/
export const setStyleNonce = (nonce: string | undefined): void => {
styleNonce = nonce;
// Update existing style elements if they exist
const existingStyleElement = document.getElementById("formbricks__css");
if (existingStyleElement && nonce) {
existingStyleElement.setAttribute("nonce", nonce);
}
const existingCustomStyleElement = document.getElementById("formbricks__css__custom");
if (existingCustomStyleElement && nonce) {
existingCustomStyleElement.setAttribute("nonce", nonce);
}
};
export const getStyleNonce = (): string | undefined => {
return styleNonce;
};
export const addStylesToDom = () => {
if (document.getElementById("formbricks__css") === null) {
const styleElement = document.createElement("style");
styleElement.id = "formbricks__css";
// Apply nonce if available
if (styleNonce) {
styleElement.setAttribute("nonce", styleNonce);
}
styleElement.innerHTML =
preflight + global + editorCss + datePickerCss + calendarCss + datePickerCustomCss;
document.head.appendChild(styleElement);
} else {
// If style element already exists, update its nonce if needed
const existingStyleElement = document.getElementById("formbricks__css");
if (existingStyleElement && styleNonce && !existingStyleElement.getAttribute("nonce")) {
existingStyleElement.setAttribute("nonce", styleNonce);
}
}
};
export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TSurveyStyling }): void => {
// Check if the style element already exists
let styleElement = document.getElementById("formbricks__css__custom");
let styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement | null;
// If the style element doesn't exist, create it and append to the head
if (!styleElement) {
// If the style element exists, update nonce if needed
if (styleElement) {
// Update nonce if it wasn't set before
if (styleNonce && !styleElement.getAttribute("nonce")) {
styleElement.setAttribute("nonce", styleNonce);
}
} else {
// Create it and append to the head
styleElement = document.createElement("style");
styleElement.id = "formbricks__css__custom";
// Apply nonce if available
if (styleNonce) {
styleElement.setAttribute("nonce", styleNonce);
}
document.head.appendChild(styleElement);
}

View File

@@ -7,6 +7,8 @@ declare global {
renderSurveyModal: (props: SurveyContainerProps) => void;
renderSurvey: (props: SurveyContainerProps) => void;
onFilePick: (files: { name: string; type: string; base64: string }[]) => void;
setNonce: (nonce: string | undefined) => void;
};
__formbricksNonce?: string;
}
}