Compare commits

...

23 Commits

Author SHA1 Message Date
Dhruwang 67161155a9 fix build 2026-01-08 15:48:45 +05:30
Dhruwang 9b0cf5f532 refactor: update validation rule types for survey elements
- Replace TValidationRule with specific validation rule types for each survey element in their respective forms.
- Ensure type safety by introducing TValidationRulesFor* types for Address, Cal, Consent, Contact Info, CTA, Date, File Upload, Matrix, Multiple Choice, NPS, Open Text, Picture Selection, Ranking, and Rating elements.
- Update the ZSurveyElement definitions to include the new validation rules schemas.
2026-01-08 15:14:29 +05:30
Dhruwang a32241d7c8 fix sonar and code rabbit issues 2026-01-08 14:34:12 +05:30
Dhruwang a296ad189a fix: resolve linting issues in validation rules editor component 2026-01-08 14:08:13 +05:30
Dhruwang 942cb0f8d0 fix lint error 2026-01-08 13:53:06 +05:30
Dhruwang 3e3b8cc349 feat: add validation rules editor UI and refactor required logic
- Add ValidationRulesEditor component with drag-and-drop support
- Integrate validation rules editor into all element forms
- Refactor element components to derive 'required' state from validationRules
- Remove legacy 'required' toggle from element form inputs
- Update Headline component to use validationRules instead of required prop
- Remove unused translation keys (input_type, maximum, minimum, character_limit_toggle)
- Fix TypeScript serialization issue by using z.lazy for validation rules schema
- Simplify element type definitions by removing narrowed validation rule types
2026-01-08 13:47:14 +05:30
Dhruwang Jariwala 63fe32a786 chore: parallel processing in lingo.dev (#7080) 2026-01-08 05:03:31 +00:00
Matti Nannt 84c465f974 fix: ensure deterministic instanceId via secondary sort key (#7070) 2026-01-07 14:04:56 +00:00
Johannes 6a33498737 feat: Custom HTML scripts in link surveys (#7064)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-01-07 10:06:41 +00:00
Matti Nannt 5130c747d4 chore: license server staging config (#7075)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-01-07 09:50:18 +00:00
Dhruwang Jariwala f5583d2652 fix: add background color to button URL input in CTA element form (#7077) 2026-01-07 09:17:38 +00:00
Fahleen Arif e0d75914a4 fix: update placeholder text for name input field in invite members form (#7054)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-01-07 08:18:36 +00:00
Dhruwang Jariwala f02ca1cfe1 chore: remove string concatenation welcome card (#7073)
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
2026-01-07 07:25:20 +00:00
Anshuman Pandey 4ade83f189 fix: contacts refresh button (#7066) 2026-01-06 12:31:20 +00:00
Jagadish Madavalkar f1fc9fea2c fix: api-wrapper returns valid malformed response (#7053)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-01-06 10:24:39 +00:00
Dhruwang Jariwala 25266e4566 fix: disappearing survey preview (#7065) 2026-01-06 06:23:11 +00:00
Matti Nannt b960cfd2a1 chore: harden CSP and X-Frame-Options headers (#7062)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-01-06 06:21:19 +00:00
Matti Nannt 9e1d1c1dc2 feat: implement robust database seeding strategy (#7017)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-01-05 15:58:58 +00:00
Matti Nannt 8c63a9f7af chore: remove debug log from next.config.mjs (#7063) 2026-01-05 15:52:04 +00:00
Anshuman Pandey fff0a7f052 fix: fixes duplicate userId issue with the contacts UI (#7051)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-01-05 09:21:50 +00:00
Anshuman Pandey 0ecc8aabff fix: fixes single use multi lang surveyUrl issue (#7057) 2026-01-05 06:08:15 +00:00
Dhruwang Jariwala 01cc0ab64d fix: correct typo in recontact waiting time description and adjust da… (#7056) 2026-01-05 06:02:28 +00:00
Anshuman Pandey 1d125bdac2 fix: fixes user api attribute override error (#7050) 2026-01-05 05:55:22 +00:00
133 changed files with 5216 additions and 949 deletions
+3
View File
@@ -168,6 +168,9 @@ SLACK_CLIENT_SECRET=
# Enterprise License Key
ENTERPRISE_LICENSE_KEY=
# Internal Environment (production, staging) - used for internal staging environment
# ENVIRONMENT=production
# Automatically assign new users to a specific organization and role within that organization
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
# (Role Management is an Enterprise feature)
@@ -213,6 +213,7 @@ export const SurveyAnalysisCTA = ({
isFormbricksCloud={isFormbricksCloud}
isReadOnly={isReadOnly}
isStorageConfigured={isStorageConfigured}
projectCustomScripts={project.customHeadScripts}
/>
)}
<SuccessMessage environment={environment} survey={survey} />
@@ -3,6 +3,7 @@
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import {
Code2Icon,
CodeIcon,
Link2Icon,
MailIcon,
QrCodeIcon,
@@ -18,6 +19,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { AnonymousLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab";
import { AppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab";
import { CustomHtmlTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/custom-html-tab";
import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab";
import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab";
import { LinkSettingsTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/link-settings-tab";
@@ -51,6 +53,7 @@ interface ShareSurveyModalProps {
isFormbricksCloud: boolean;
isReadOnly: boolean;
isStorageConfigured: boolean;
projectCustomScripts?: string | null;
}
export const ShareSurveyModal = ({
@@ -65,6 +68,7 @@ export const ShareSurveyModal = ({
isFormbricksCloud,
isReadOnly,
isStorageConfigured,
projectCustomScripts,
}: ShareSurveyModalProps) => {
const environmentId = survey.environmentId;
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
@@ -191,9 +195,24 @@ export const ShareSurveyModal = ({
componentType: PrettyUrlTab,
componentProps: { publicDomain, isReadOnly },
},
{
id: ShareSettingsType.CUSTOM_HTML,
type: LinkTabsType.SHARE_SETTING,
label: t("environments.surveys.share.custom_html.nav_title"),
icon: CodeIcon,
title: t("environments.surveys.share.custom_html.nav_title"),
description: t("environments.surveys.share.custom_html.description"),
componentType: CustomHtmlTab,
componentProps: { projectCustomScripts, isReadOnly },
},
];
return isFormbricksCloud ? tabs.filter((tab) => tab.id !== ShareSettingsType.PRETTY_URL) : tabs;
// Filter out tabs that should not be shown on Formbricks Cloud
return isFormbricksCloud
? tabs.filter(
(tab) => tab.id !== ShareSettingsType.PRETTY_URL && tab.id !== ShareSettingsType.CUSTOM_HTML
)
: tabs;
}, [
t,
survey,
@@ -207,6 +226,7 @@ export const ShareSurveyModal = ({
isFormbricksCloud,
email,
isStorageConfigured,
projectCustomScripts,
]);
const getDefaultActiveId = useCallback(() => {
@@ -2,7 +2,7 @@
import { CirclePlayIcon, CopyIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -34,7 +34,6 @@ export const AnonymousLinksTab = ({
locale,
isReadOnly,
}: AnonymousLinksTabProps) => {
const surveyUrlWithCustomSuid = `${surveyUrl}?suId=CUSTOM-ID`;
const router = useRouter();
const { t } = useTranslation();
@@ -49,6 +48,12 @@ export const AnonymousLinksTab = ({
pendingAction: () => Promise<void> | void;
} | null>(null);
const surveyUrlWithCustomSuid = useMemo(() => {
const url = new URL(surveyUrl);
url.searchParams.set("suId", "CUSTOM-ID");
return url.toString();
}, [surveyUrl]);
const resetState = () => {
const { singleUse } = survey;
const { enabled, isEncrypted } = singleUse ?? {};
@@ -177,7 +182,11 @@ export const AnonymousLinksTab = ({
if (!!response?.data?.length) {
const singleUseIds = response.data;
const surveyLinks = singleUseIds.map((singleUseId) => `${surveyUrl}?suId=${singleUseId}`);
const surveyLinks = singleUseIds.map((singleUseId) => {
const url = new URL(surveyUrl);
url.searchParams.set("suId", singleUseId);
return url.toString();
});
// Create content with just the links
const csvContent = surveyLinks.join("\n");
@@ -0,0 +1,163 @@
"use client";
import { AlertTriangleIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { cn } from "@/lib/cn";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { TabToggle } from "@/modules/ui/components/tab-toggle";
interface CustomHtmlTabProps {
projectCustomScripts: string | null | undefined;
isReadOnly: boolean;
}
interface CustomHtmlFormData {
customHeadScripts: string;
customHeadScriptsMode: TSurvey["customHeadScriptsMode"];
}
export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTabProps) => {
const { t } = useTranslation();
const { survey } = useSurvey();
const [isSaving, setIsSaving] = useState(false);
const form = useForm<CustomHtmlFormData>({
defaultValues: {
customHeadScripts: survey.customHeadScripts ?? "",
customHeadScriptsMode: survey.customHeadScriptsMode ?? "add",
},
});
const {
handleSubmit,
watch,
setValue,
reset,
formState: { isDirty },
} = form;
const scriptsMode = watch("customHeadScriptsMode");
const onSubmit = async (data: CustomHtmlFormData) => {
if (isSaving || isReadOnly) return;
setIsSaving(true);
const updatedSurvey: TSurvey = {
...survey,
customHeadScripts: data.customHeadScripts || null,
customHeadScriptsMode: data.customHeadScriptsMode,
};
const result = await updateSurveyAction(updatedSurvey);
if (result?.data) {
toast.success(t("environments.surveys.share.custom_html.saved_successfully"));
reset(data);
} else {
toast.error(t("common.something_went_wrong_please_try_again"));
}
setIsSaving(false);
};
return (
<div className="px-1">
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Mode Toggle */}
<div className="space-y-2">
<FormLabel>{t("environments.surveys.share.custom_html.script_mode")}</FormLabel>
<TabToggle
id="custom-scripts-mode"
options={[
{ value: "add", label: t("environments.surveys.share.custom_html.add_to_workspace") },
{ value: "replace", label: t("environments.surveys.share.custom_html.replace_workspace") },
]}
defaultSelected={scriptsMode ?? "add"}
onChange={(value) => setValue("customHeadScriptsMode", value, { shouldDirty: true })}
disabled={isReadOnly}
/>
<p className="text-sm text-slate-500">
{scriptsMode === "add"
? t("environments.surveys.share.custom_html.add_mode_description")
: t("environments.surveys.share.custom_html.replace_mode_description")}
</p>
</div>
{/* Workspace Scripts Preview */}
{projectCustomScripts && (
<div className={scriptsMode === "replace" ? "opacity-50" : ""}>
<FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel>
<div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3">
<pre className="font-mono text-xs whitespace-pre-wrap text-slate-600">
{projectCustomScripts}
</pre>
</div>
</div>
)}
{!projectCustomScripts && (
<div className="rounded-md border border-slate-200 bg-slate-50 p-3">
<p className="text-sm text-slate-500">
{t("environments.surveys.share.custom_html.no_workspace_scripts")}
</p>
</div>
)}
{/* Survey Scripts */}
<FormField
control={form.control}
name="customHeadScripts"
render={({ field }) => (
<FormItem>
<FormLabel>{t("environments.surveys.share.custom_html.survey_scripts_label")}</FormLabel>
<FormDescription>
{t("environments.surveys.share.custom_html.survey_scripts_description")}
</FormDescription>
<FormControl>
<textarea
rows={8}
placeholder={t("environments.surveys.share.custom_html.placeholder")}
className={cn(
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
)}
{...field}
disabled={isReadOnly}
/>
</FormControl>
</FormItem>
)}
/>
{/* Save Button */}
<Button type="submit" disabled={isSaving || isReadOnly || !isDirty}>
{isSaving ? t("common.saving") : t("common.save")}
</Button>
{/* Security Warning */}
<Alert variant="warning" className="flex items-start gap-2">
<AlertTriangleIcon className="mt-0.5 h-4 w-4 shrink-0" />
<AlertDescription>
{t("environments.surveys.share.custom_html.security_warning")}
</AlertDescription>
</Alert>
</form>
</FormProvider>
</div>
);
};
@@ -13,6 +13,7 @@ export enum ShareViaType {
export enum ShareSettingsType {
LINK_SETTINGS = "link-settings",
PRETTY_URL = "pretty-url",
CUSTOM_HTML = "custom-html",
}
export enum LinkTabsType {
+2 -2
View File
@@ -4835,7 +4835,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
segment: null,
blocks: [
{
id: createId(),
id: "cltxxaa6x0000g8hacxdxeje1",
name: "Block 1",
elements: [
{
@@ -4857,7 +4857,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
backButtonLabel: createI18nString(t("templates.preview_survey_question_2_back_button_label"), []),
},
{
id: createId(),
id: "cltxxaa6x0000g8hacxdxeje2",
name: "Block 2",
elements: [
{
+37 -6
View File
@@ -211,7 +211,6 @@ checksums:
common/imprint: c4e5f2a1994d3cc5896b200709cc499c
common/in_progress: 3de9afebcb9d4ce8ac42e14995f79ffd
common/inactive_surveys: 324b8e1844739cdc2a3bc71aef143a76
common/input_type: df4865b5d0a598a8d7f563dcec104df5
common/integration: 40d02f65c4356003e0e90ffb944907d2
common/integrations: 0ccce343287704cd90150c32e2fcad36
common/invalid_date: 4c18c82f7317d4a02f8d5fef611e82b7
@@ -235,13 +234,11 @@ checksums:
common/look_and_feel: 9125503712626d495cedec7a79f1418c
common/manage: a3d40c0267b81ae53c9598eaeb05087d
common/marketing: fcf0f06f8b64b458c7ca6d95541a3cc8
common/maximum: 4c07541dd1f093775bdc61b559cca6c8
common/member: 1606dc30b369856b9dba1fe9aec425d2
common/members: 0932e80cba1e3e0a7f52bb67ff31da32
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
common/metadata: 695d4f7da261ba76e3be4de495491028
common/minimum: d9759235086d0169928b3c1401115e22
common/mobile_overlay_app_works_best_on_desktop: 4509f7bfbb4edbd42e534042d6cb7e72
common/mobile_overlay_surveys_look_good: 6d73b635018b4a5a89cce58e1d2497f5
common/mobile_overlay_title: 42f52259b7527989fb3a3240f5352a8b
@@ -1149,8 +1146,6 @@ checksums:
environments/surveys/edit/change_the_question_color_of_the_survey: ab6942138a8c5fc6c8c3b9f8dd95e980
environments/surveys/edit/changes_saved: 90aab363c9e96eaa1295a997c48f97f6
environments/surveys/edit/changing_survey_type_will_remove_existing_distribution_channels: 9ce817be04f13f2f0db981145ec48df4
environments/surveys/edit/character_limit_toggle_description: d15a6895eaaf4d4c7212d9240c6bf45d
environments/surveys/edit/character_limit_toggle_title: fdc45bcc6335e5116aec895fecda0d87
environments/surveys/edit/checkbox_label: 12a07d6bdf38e283a2e95892ec49b7f8
environments/surveys/edit/choose_the_actions_which_trigger_the_survey: 773b311a148a112243f3b139506b9987
environments/surveys/edit/choose_the_first_question_on_your_block: bdece06ca04f89d0c445ba1554dd5b80
@@ -1516,6 +1511,21 @@ checksums:
environments/surveys/edit/upper_label: 1fa48bce3fade6ffc1a52d9fdddf9e17
environments/surveys/edit/url_filters: e524879d2eb74463d7fd06a7e0f53421
environments/surveys/edit/url_not_supported: af8a753467c617b596aadef1aaaed664
environments/surveys/edit/validation/characters: f62970e214bd04fd1959e2759ee1ec48
environments/surveys/edit/validation/email: a481cd9fba3e145252458ee1eaa9bd3b
environments/surveys/edit/validation/max_length: dad68e07f6ee06ed11ec6bda2e896c68
environments/surveys/edit/validation/max_selections: 6edf9e1149c3893da102d9464138da22
environments/surveys/edit/validation/max_value: 6109d595ba21497c59b1c91d7fd09a13
environments/surveys/edit/validation/min_length: ad5c57a937565826794fb865522962e8
environments/surveys/edit/validation/min_selections: 204dbf1f1b3aa34c8b981642b1694262
environments/surveys/edit/validation/min_value: b9542ab0e0ea0ee18e82931b160b1385
environments/surveys/edit/validation/options_selected: 088309b017c07c01494447dba82b2621
environments/surveys/edit/validation/pattern: c6f01d7bc9baa21a40ea38fa986bd5a0
environments/surveys/edit/validation/phone: bcd7bd37a475ab1f80ea4c5b4d4d0bb5
environments/surveys/edit/validation/required: b6c231d5d1a8dfe37615d1efd38ed8e0
environments/surveys/edit/validation/url: 4006a4d8dfac013758f0053f6aa67cdd
environments/surveys/edit/validation_rules: 0cd99f02684d633196c8b249e857d207
environments/surveys/edit/validation_rules_description: a0a7cee05e18efd462148698e3a93399
environments/surveys/edit/variable_is_used_in_logic_of_question_please_remove_it_from_logic_first: bd9d9c7cf0be671c4e8cf67e2ae6659e
environments/surveys/edit/variable_is_used_in_quota_please_remove_it_from_quota_first: 0d36e5b2713f5450fe346e0af0aaa29c
environments/surveys/edit/variable_name_is_already_taken_please_choose_another: 6da42fe8733c6379158bce9a176f76d7
@@ -1609,6 +1619,20 @@ checksums:
environments/surveys/share/anonymous_links/source_tracking: dcf85834f1ba490347a301ab55d32402
environments/surveys/share/anonymous_links/url_encryption_description: 1509056fdae7b42fc85f1ee3c49de4c3
environments/surveys/share/anonymous_links/url_encryption_label: 9c70fd3f64cf8cc5039b198d3af79d14
environments/surveys/share/custom_html/add_mode_description: f48dcf53bce27cc40c3546547e8395cb
environments/surveys/share/custom_html/add_to_workspace: af9cd24872f25cfc4231b926acc76d7c
environments/surveys/share/custom_html/description: 0634048655de8b4b17b41d496e1ea457
environments/surveys/share/custom_html/nav_title: 01f993f027ab277058eacb8a48ea7c01
environments/surveys/share/custom_html/no_workspace_scripts: 7fc57f576c98e96ee73e7b489345d51a
environments/surveys/share/custom_html/placeholder: 229eb1676a69311ff1dcc19c1a52c080
environments/surveys/share/custom_html/replace_mode_description: 6eaf17275c02b0d5ac21255747f36271
environments/surveys/share/custom_html/replace_workspace: b80e698cc8790246fea42453bfa4b09d
environments/surveys/share/custom_html/saved_successfully: 14e7d2d646803ac1dd24cfa45c22606c
environments/surveys/share/custom_html/script_mode: 60ed1102dd42ad14e272df5f6921b423
environments/surveys/share/custom_html/security_warning: 5faa0f284d48110918a5e8a467e2bcb8
environments/surveys/share/custom_html/survey_scripts_description: 948746d51db23b348164105c175391b3
environments/surveys/share/custom_html/survey_scripts_label: 095d9fe768abe2bb32428184ee1c9b5a
environments/surveys/share/custom_html/workspace_scripts_label: 3d9b6c09eae10a2bacb3ac96b4db4a19
environments/surveys/share/dynamic_popup/alert_button: 8932096e3eee837beeb21dd4afd8b662
environments/surveys/share/dynamic_popup/alert_description: 53d2ba39984a059a5eca4cb6cf9ba00d
environments/surveys/share/dynamic_popup/alert_title: 813a9160940894da26ec2a09bbb1a7bf
@@ -1820,13 +1844,20 @@ checksums:
environments/workspace/app-connection/setup_alert_title: 9561cca2b391e0df81e8a982921ff2bb
environments/workspace/app-connection/webapp_url: d64d8cc3c4c4ecce780d94755f7e4de9
environments/workspace/general/cannot_delete_only_workspace: 853f32a75d92b06eaccc0d43d767c183
environments/workspace/general/custom_scripts: a6a06a2e20764d76d3e22e5e17d98dbb
environments/workspace/general/custom_scripts_card_description: 1585c47126e4b68f9f79f232631c67a1
environments/workspace/general/custom_scripts_description: 1c477e711fc08850b2ab70d98ffe18d6
environments/workspace/general/custom_scripts_label: 3b189dd62ae0cc35d616e04af90f0b38
environments/workspace/general/custom_scripts_placeholder: 229eb1676a69311ff1dcc19c1a52c080
environments/workspace/general/custom_scripts_updated_successfully: eabe8e6ededa86342d59093fe308c681
environments/workspace/general/custom_scripts_warning: 5faa0f284d48110918a5e8a467e2bcb8
environments/workspace/general/delete_workspace: 3badbc0f4b49644986fc19d8b2d8f317
environments/workspace/general/delete_workspace_confirmation: 54a4ee78867537e0244c7170453cdb3f
environments/workspace/general/delete_workspace_name_includes_surveys_responses_people_and_more: 11e9ac5a799fbec22495f92f42c40d98
environments/workspace/general/delete_workspace_settings_description: 411ef100f167fc8fca64e833b6c0d030
environments/workspace/general/error_saving_workspace_information: e7b8022785619ef34de1fb1630b3c476
environments/workspace/general/only_owners_or_managers_can_delete_workspaces: 58da180cd2610210302d85a9896d80bd
environments/workspace/general/recontact_waiting_time: 8977b5160fbf88c456608982b33e246f
environments/workspace/general/recontact_waiting_time: 6873c18d51830e2cadef67cce6a2c95c
environments/workspace/general/recontact_waiting_time_settings_description: ebd64fddbea9387b12c027a18358db7e
environments/workspace/general/this_action_cannot_be_undone: 3d8b13374ffd3cefc0f3f7ce077bd9c9
environments/workspace/general/wait_x_days_before_showing_next_survey: d96228788d32ec23dc0d8c8ba77150a6
+2
View File
@@ -23,6 +23,7 @@ export const env = createEnv({
EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
ENCRYPTION_KEY: z.string(),
ENTERPRISE_LICENSE_KEY: z.string().optional(),
ENVIRONMENT: z.enum(["production", "staging"]).default("production"),
GITHUB_ID: z.string().optional(),
GITHUB_SECRET: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
@@ -151,6 +152,7 @@ export const env = createEnv({
EMAIL_VERIFICATION_DISABLED: process.env.EMAIL_VERIFICATION_DISABLED,
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
ENTERPRISE_LICENSE_KEY: process.env.ENTERPRISE_LICENSE_KEY,
ENVIRONMENT: process.env.ENVIRONMENT,
GITHUB_ID: process.env.GITHUB_ID,
GITHUB_SECRET: process.env.GITHUB_SECRET,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
+1 -1
View File
@@ -21,7 +21,7 @@ export type TInstanceInfo = {
export const getInstanceInfo = reactCache(async (): Promise<TInstanceInfo | null> => {
try {
const oldestOrg = await prisma.organization.findFirst({
orderBy: { createdAt: "asc" },
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
select: { id: true, createdAt: true },
});
+1
View File
@@ -26,6 +26,7 @@ const selectProject = {
environments: true,
styling: true,
logo: true,
customHeadScripts: true,
};
export const getUserProjects = reactCache(
@@ -268,6 +268,8 @@ export const mockSyncSurveyOutput: SurveyMock = {
showLanguageSwitch: null,
metadata: {},
slug: null,
customHeadScripts: null,
customHeadScriptsMode: null,
};
export const mockSurveyOutput: SurveyMock = {
@@ -292,6 +294,8 @@ export const mockSurveyOutput: SurveyMock = {
showLanguageSwitch: null,
...baseSurveyProperties,
slug: null,
customHeadScripts: null,
customHeadScriptsMode: null,
};
export const createSurveyInput: TSurveyCreateInput = {
@@ -322,6 +326,8 @@ export const updateSurveyInput: TSurvey = {
...baseSurveyProperties,
...commonMockProperties,
slug: null,
customHeadScripts: null,
customHeadScriptsMode: null,
};
export const mockTransformedSurveyOutput = {
@@ -574,4 +580,6 @@ export const mockSurveyWithLogic: TSurvey = {
{ id: "siog1dabtpo3l0a3xoxw2922", type: "text", name: "var1", value: "lmao" },
{ id: "km1srr55owtn2r7lkoh5ny1u", type: "number", name: "var2", value: 32 },
],
customHeadScripts: null,
customHeadScriptsMode: null,
};
+4
View File
@@ -65,6 +65,8 @@ export const selectSurvey = {
showLanguageSwitch: true,
recaptcha: true,
metadata: true,
customHeadScripts: true,
customHeadScriptsMode: true,
languages: {
select: {
default: true,
@@ -563,6 +565,7 @@ export const updateSurveyInternal = async (
...prismaSurvey, // Properties from prismaSurvey
displayPercentage: Number(prismaSurvey.displayPercentage) || null,
segment: surveySegment,
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
};
return modifiedSurvey;
@@ -783,6 +786,7 @@ export const loadNewSegmentInSurvey = async (surveyId: string, newSegmentId: str
const modifiedSurvey: TSurvey = {
...prismaSurvey, // Properties from prismaSurvey
segment: surveySegment,
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
};
return modifiedSurvey;
+1
View File
@@ -29,6 +29,7 @@ export const transformPrismaSurvey = <T extends TSurvey | TJsEnvironmentStateSur
...surveyPrisma,
displayPercentage: Number(surveyPrisma.displayPercentage) || null,
segment,
customHeadScriptsMode: surveyPrisma.customHeadScriptsMode,
} as T;
return transformedSurvey;
+40 -5
View File
@@ -238,7 +238,6 @@
"imprint": "Impressum",
"in_progress": "Im Gange",
"inactive_surveys": "Inaktive Umfragen",
"input_type": "Eingabetyp",
"integration": "Integration",
"integrations": "Integrationen",
"invalid_date": "Ungültiges Datum",
@@ -262,13 +261,11 @@
"look_and_feel": "Darstellung",
"manage": "Verwalten",
"marketing": "Marketing",
"maximum": "Maximal",
"member": "Mitglied",
"members": "Mitglieder",
"members_and_teams": "Mitglieder & Teams",
"membership_not_found": "Mitgliedschaft nicht gefunden",
"metadata": "Metadaten",
"minimum": "Minimum",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
"mobile_overlay_surveys_look_good": "Keine Sorge deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
"mobile_overlay_title": "Oops, Bildschirm zu klein erkannt!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "Fragefarbe der Umfrage ändern.",
"changes_saved": "Änderungen gespeichert.",
"changing_survey_type_will_remove_existing_distribution_channels": "\"Das Ändern des Umfragetypen beeinflusst, wie er geteilt werden kann. Wenn Teilnehmer bereits Zugriffslinks für den aktuellen Typ haben, könnten sie das Zugriffsrecht nach dem Wechsel verlieren.\"",
"character_limit_toggle_description": "Begrenzen Sie, wie kurz oder lang eine Antwort sein kann.",
"character_limit_toggle_title": "Fügen Sie Zeichenbeschränkungen hinzu",
"checkbox_label": "Checkbox-Beschriftung",
"choose_the_actions_which_trigger_the_survey": "Aktionen auswählen, die die Umfrage auslösen.",
"choose_the_first_question_on_your_block": "Wählen sie die erste frage in ihrem block",
@@ -1589,6 +1584,23 @@
"upper_label": "Oberes Label",
"url_filters": "URL-Filter",
"url_not_supported": "URL nicht unterstützt",
"validation": {
"characters": "Zeichen",
"email": "Ist gültige E-Mail",
"max_length": "Ist kürzer als",
"max_selections": "Höchstens",
"max_value": "Ist weniger als",
"min_length": "Ist länger als",
"min_selections": "Mindestens",
"min_value": "Ist größer als",
"options_selected": "Optionen ausgewählt",
"pattern": "Entspricht Regex-Muster",
"phone": "Ist gültige Telefonnummer",
"required": "Ist erforderlich",
"url": "Ist gültige URL"
},
"validation_rules": "Validierungsregeln",
"validation_rules_description": "Nur Antworten akzeptieren, die die folgenden Kriterien erfüllen",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" wird in der \"{quotaName}\" Quote verwendet",
"variable_name_is_already_taken_please_choose_another": "Variablenname ist bereits vergeben, bitte wähle einen anderen.",
@@ -1690,6 +1702,22 @@
"url_encryption_description": "Nur deaktivieren, wenn Sie eine benutzerdefinierte Einmal-ID setzen müssen.",
"url_encryption_label": "Verschlüsselung der URL für einmalige Nutzung ID"
},
"custom_html": {
"add_mode_description": "Umfrage-Skripte werden zusätzlich zu den Workspace-Skripten ausgeführt.",
"add_to_workspace": "Zu Workspace-Skripten hinzufügen",
"description": "Tracking-Skripte und Pixel zu dieser Umfrage hinzufügen",
"nav_title": "Benutzerdefiniertes HTML",
"no_workspace_scripts": "Keine Workspace-Skripte konfiguriert. Sie können diese in Workspace-Einstellungen → Allgemein hinzufügen.",
"placeholder": "<!-- Fügen Sie hier Ihre Tracking-Skripte ein -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"replace_mode_description": "Nur Umfrage-Skripte werden ausgeführt. Workspace-Skripte werden ignoriert. Leer lassen, um keine Skripte zu laden.",
"replace_workspace": "Workspace-Skripte ersetzen",
"saved_successfully": "Benutzerdefinierte Skripte erfolgreich gespeichert",
"script_mode": "Skript-Modus",
"security_warning": "Skripte werden mit vollem Browser-Zugriff ausgeführt. Fügen Sie nur Skripte aus vertrauenswürdigen Quellen hinzu.",
"survey_scripts_description": "Benutzerdefiniertes HTML hinzufügen, das in den <head> dieser Umfrageseite eingefügt wird.",
"survey_scripts_label": "Umfragespezifische Skripte",
"workspace_scripts_label": "Workspace-Skripte (vererbt)"
},
"dynamic_popup": {
"alert_button": "Umfrage bearbeiten",
"alert_description": "Diese Umfrage ist derzeit als Link-Umfrage konfiguriert, die dynamische Pop-ups nicht unterstützt. Sie können dies im Tab Einstellungen im Umfrage-Editor ändern.",
@@ -1929,6 +1957,13 @@
},
"general": {
"cannot_delete_only_workspace": "Dies ist Ihr einziges Projekt, es kann nicht gelöscht werden. Erstellen Sie zuerst ein neues Projekt.",
"custom_scripts": "Benutzerdefinierte Skripte",
"custom_scripts_card_description": "Tracking-Skripte und Pixel zu allen Link-Umfragen in diesem Workspace hinzufügen.",
"custom_scripts_description": "Skripte werden in den <head> aller Link-Umfrageseiten eingefügt.",
"custom_scripts_label": "HTML-Skripte",
"custom_scripts_placeholder": "<!-- Fügen Sie hier Ihre Tracking-Skripte ein -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Benutzerdefinierte Skripte erfolgreich aktualisiert",
"custom_scripts_warning": "Skripte werden mit vollem Browser-Zugriff ausgeführt. Fügen Sie nur Skripte aus vertrauenswürdigen Quellen hinzu.",
"delete_workspace": "Projekt löschen",
"delete_workspace_confirmation": "Sind Sie sicher, dass Sie {projectName} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "{projectName} inkl. aller Umfragen, Antworten, Personen, Aktionen und Attribute löschen.",
+42 -7
View File
@@ -238,7 +238,6 @@
"imprint": "Imprint",
"in_progress": "In Progress",
"inactive_surveys": "Inactive surveys",
"input_type": "Input type",
"integration": "integration",
"integrations": "Integrations",
"invalid_date": "Invalid date",
@@ -262,13 +261,11 @@
"look_and_feel": "Look & Feel",
"manage": "Manage",
"marketing": "Marketing",
"maximum": "Maximum",
"member": "Member",
"members": "Members",
"members_and_teams": "Members & Teams",
"membership_not_found": "Membership not found",
"metadata": "Metadata",
"minimum": "Minimum",
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
"mobile_overlay_surveys_look_good": "Don't worry your surveys look great on every device and screen size!",
"mobile_overlay_title": "Oops, tiny screen detected!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "Change the question color of the survey.",
"changes_saved": "Changes saved.",
"changing_survey_type_will_remove_existing_distribution_channels": "Changing the survey type will affect how it can be shared. If respondents already have access links for the current type, they may lose access after the switch.",
"character_limit_toggle_description": "Limit how short or long an answer can be.",
"character_limit_toggle_title": "Add character limits",
"checkbox_label": "Checkbox Label",
"choose_the_actions_which_trigger_the_survey": "Choose the actions which trigger the survey.",
"choose_the_first_question_on_your_block": "Choose the first question on your Block",
@@ -1589,6 +1584,23 @@
"upper_label": "Upper Label",
"url_filters": "URL Filters",
"url_not_supported": "URL not supported",
"validation": {
"characters": "characters",
"email": "Is valid email",
"max_length": "Is shorter than",
"max_selections": "At most",
"max_value": "Is less than",
"min_length": "Is longer than",
"min_selections": "At least",
"min_value": "Is greater than",
"options_selected": "options selected",
"pattern": "Matches regex pattern",
"phone": "Is valid phone",
"required": "Is required",
"url": "Is valid URL"
},
"validation_rules": "Validation rules",
"validation_rules_description": "Only accept responses that meet the following criteria",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} is used in logic of question {questionIndex}. Please remove it from logic first.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" is being used in \"{quotaName}\" quota",
"variable_name_is_already_taken_please_choose_another": "Variable name is already taken, please choose another.",
@@ -1690,6 +1702,22 @@
"url_encryption_description": "Only disable if you need to set a custom single-use ID.",
"url_encryption_label": "URL encryption of single-use ID"
},
"custom_html": {
"add_mode_description": "Survey scripts will run in addition to workspace-level scripts.",
"add_to_workspace": "Add to Workspace scripts",
"description": "Add tracking scripts and pixels to this survey",
"nav_title": "Custom HTML",
"no_workspace_scripts": "No workspace-level scripts configured. You can add them in Workspace Settings → General.",
"placeholder": "<!-- Paste your tracking scripts here -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"replace_mode_description": "Only survey scripts will run. Workspace scripts will be ignored. Keep empty to not load any scripts.",
"replace_workspace": "Replace Workspace scripts",
"saved_successfully": "Custom scripts saved successfully",
"script_mode": "Script Mode",
"security_warning": "Scripts execute with full browser access. Only add scripts from trusted sources.",
"survey_scripts_description": "Add custom HTML to inject into the <head> of this survey page.",
"survey_scripts_label": "Survey-specific scripts",
"workspace_scripts_label": "Workspace scripts (inherited)"
},
"dynamic_popup": {
"alert_button": "Edit survey",
"alert_description": "This survey is currently configured as a link survey, which does not support dynamic pop-ups. You can change this in the settings tab of the survey editor.",
@@ -1929,13 +1957,20 @@
},
"general": {
"cannot_delete_only_workspace": "This is your only workspace, it cannot be deleted. Create a new workspace first.",
"custom_scripts": "Custom Scripts",
"custom_scripts_card_description": "Add tracking scripts and pixels to all link surveys in this workspace.",
"custom_scripts_description": "Scripts will be injected into the <head> of all link survey pages.",
"custom_scripts_label": "HTML Scripts",
"custom_scripts_placeholder": "<!-- Paste your tracking scripts here -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Custom scripts updated successfully",
"custom_scripts_warning": "Scripts execute with full browser access. Only add scripts from trusted sources.",
"delete_workspace": "Delete Workspace",
"delete_workspace_confirmation": "Are you sure you want to delete {projectName}? This action cannot be undone.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Delete {projectName} incl. all surveys, responses, people, actions and attributes.",
"delete_workspace_settings_description": "Delete workspace with all surveys, responses, people, actions and attributes. This cannot be undone.",
"error_saving_workspace_information": "Error saving workspace information",
"only_owners_or_managers_can_delete_workspaces": "Only owners or managers can delete workspaces",
"recontact_waiting_time": "Cooldown Period (scross surveys)",
"recontact_waiting_time": "Cooldown Period (across surveys)",
"recontact_waiting_time_settings_description": "Control how frequently users can be surveyed across all Website & App Surveys in this workspace.",
"this_action_cannot_be_undone": "This action cannot be undone.",
"wait_x_days_before_showing_next_survey": "Wait X days before showing next survey:",
@@ -3011,4 +3046,4 @@
"usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.",
"usability_score_name": "System Usability Score (SUS)"
}
}
}
+40 -5
View File
@@ -238,7 +238,6 @@
"imprint": "Aviso legal",
"in_progress": "En progreso",
"inactive_surveys": "Encuestas inactivas",
"input_type": "Tipo de entrada",
"integration": "integración",
"integrations": "Integraciones",
"invalid_date": "Fecha no válida",
@@ -262,13 +261,11 @@
"look_and_feel": "Apariencia",
"manage": "Gestionar",
"marketing": "Marketing",
"maximum": "Máximo",
"member": "Miembro",
"members": "Miembros",
"members_and_teams": "Miembros y equipos",
"membership_not_found": "Membresía no encontrada",
"metadata": "Metadatos",
"minimum": "Mínimo",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona mejor en una pantalla más grande. Para gestionar o crear encuestas, cambia a otro dispositivo.",
"mobile_overlay_surveys_look_good": "No te preocupes ¡tus encuestas se ven geniales en todos los dispositivos y tamaños de pantalla!",
"mobile_overlay_title": "¡Ups, pantalla pequeña detectada!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "Cambiar el color de las preguntas de la encuesta.",
"changes_saved": "Cambios guardados.",
"changing_survey_type_will_remove_existing_distribution_channels": "Cambiar el tipo de encuesta afectará a cómo se puede compartir. Si los encuestados ya tienen enlaces de acceso para el tipo actual, podrían perder el acceso después del cambio.",
"character_limit_toggle_description": "Limitar lo corta o larga que puede ser una respuesta.",
"character_limit_toggle_title": "Añadir límites de caracteres",
"checkbox_label": "Etiqueta de casilla de verificación",
"choose_the_actions_which_trigger_the_survey": "Elige las acciones que activan la encuesta.",
"choose_the_first_question_on_your_block": "Elige la primera pregunta en tu bloque",
@@ -1589,6 +1584,23 @@
"upper_label": "Etiqueta superior",
"url_filters": "Filtros de URL",
"url_not_supported": "URL no compatible",
"validation": {
"characters": "caracteres",
"email": "Es un correo electrónico válido",
"max_length": "Es más corto que",
"max_selections": "Como máximo",
"max_value": "Es menor que",
"min_length": "Es más largo que",
"min_selections": "Al menos",
"min_value": "Es mayor que",
"options_selected": "opciones seleccionadas",
"pattern": "Coincide con el patrón regex",
"phone": "Es un teléfono válido",
"required": "Es obligatorio",
"url": "Es una URL válida"
},
"validation_rules": "Reglas de validación",
"validation_rules_description": "Solo aceptar respuestas que cumplan los siguientes criterios",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínala primero de la lógica.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" se está utilizando en la cuota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "El nombre de la variable ya está en uso, por favor elige otro.",
@@ -1690,6 +1702,22 @@
"url_encryption_description": "Desactiva solo si necesitas establecer un ID de uso único personalizado.",
"url_encryption_label": "Cifrado URL del ID de uso único"
},
"custom_html": {
"add_mode_description": "Los scripts de la encuesta se ejecutarán además de los scripts a nivel de espacio de trabajo.",
"add_to_workspace": "Añadir a los scripts del espacio de trabajo",
"description": "Añade scripts de seguimiento y píxeles a esta encuesta",
"nav_title": "HTML personalizado",
"no_workspace_scripts": "No hay scripts configurados a nivel de espacio de trabajo. Puedes añadirlos en Configuración del espacio de trabajo → General.",
"placeholder": "<!-- Pega tus scripts de seguimiento aquí -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"replace_mode_description": "Solo se ejecutarán los scripts de la encuesta. Los scripts del espacio de trabajo serán ignorados. Déjalo vacío para no cargar ningún script.",
"replace_workspace": "Reemplazar scripts del espacio de trabajo",
"saved_successfully": "Scripts personalizados guardados correctamente",
"script_mode": "Modo de script",
"security_warning": "Los scripts se ejecutan con acceso completo al navegador. Solo añade scripts de fuentes confiables.",
"survey_scripts_description": "Añade HTML personalizado para inyectar en el <head> de esta página de encuesta.",
"survey_scripts_label": "Scripts específicos de la encuesta",
"workspace_scripts_label": "Scripts del espacio de trabajo (heredados)"
},
"dynamic_popup": {
"alert_button": "Editar encuesta",
"alert_description": "Esta encuesta está actualmente configurada como una encuesta de enlace, que no admite ventanas emergentes dinámicas. Puedes cambiar esto en la pestaña de ajustes del editor de encuestas.",
@@ -1929,6 +1957,13 @@
},
"general": {
"cannot_delete_only_workspace": "Este es tu único proyecto, no se puede eliminar. Crea primero un proyecto nuevo.",
"custom_scripts": "Scripts personalizados",
"custom_scripts_card_description": "Añade scripts de seguimiento y píxeles a todas las encuestas con enlace en este espacio de trabajo.",
"custom_scripts_description": "Los scripts se inyectarán en el <head> de todas las páginas de encuestas con enlace.",
"custom_scripts_label": "Scripts HTML",
"custom_scripts_placeholder": "<!-- Pega tus scripts de seguimiento aquí -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Scripts personalizados actualizados correctamente",
"custom_scripts_warning": "Los scripts se ejecutan con acceso completo al navegador. Solo añade scripts de fuentes confiables.",
"delete_workspace": "Eliminar proyecto",
"delete_workspace_confirmation": "¿Estás seguro de que quieres eliminar {projectName}? Esta acción no se puede deshacer.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Eliminar {projectName} incluyendo todas las encuestas, respuestas, personas, acciones y atributos.",
+40 -5
View File
@@ -238,7 +238,6 @@
"imprint": "Empreinte",
"in_progress": "En cours",
"inactive_surveys": "Sondages inactifs",
"input_type": "Type d'entrée",
"integration": "intégration",
"integrations": "Intégrations",
"invalid_date": "Date invalide",
@@ -262,13 +261,11 @@
"look_and_feel": "Apparence",
"manage": "Gérer",
"marketing": "Marketing",
"maximum": "Max",
"member": "Membre",
"members": "Membres",
"members_and_teams": "Membres & Équipes",
"membership_not_found": "Abonnement non trouvé",
"metadata": "Métadonnées",
"minimum": "Min",
"mobile_overlay_app_works_best_on_desktop": "Formbricks fonctionne mieux sur un écran plus grand. Pour gérer ou créer des sondages, passez à un autre appareil.",
"mobile_overlay_surveys_look_good": "Ne t'inquiète pas tes enquêtes sont superbes sur tous les appareils et tailles d'écran!",
"mobile_overlay_title": "Oups, écran minuscule détecté!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "Vous pouvez modifier la couleur des questions d'une enquête.",
"changes_saved": "Modifications enregistrées.",
"changing_survey_type_will_remove_existing_distribution_channels": "Le changement du type de sondage affectera la façon dont il peut être partagé. Si les répondants ont déjà des liens d'accès pour le type actuel, ils peuvent perdre l'accès après le changement.",
"character_limit_toggle_description": "Limitez la longueur des réponses.",
"character_limit_toggle_title": "Ajouter des limites de caractères",
"checkbox_label": "Étiquette de case à cocher",
"choose_the_actions_which_trigger_the_survey": "Choisissez les actions qui déclenchent l'enquête.",
"choose_the_first_question_on_your_block": "Choisissez la première question de votre bloc",
@@ -1589,6 +1584,23 @@
"upper_label": "Étiquette supérieure",
"url_filters": "Filtres d'URL",
"url_not_supported": "URL non supportée",
"validation": {
"characters": "caractères",
"email": "Est un e-mail valide",
"max_length": "Est plus court que",
"max_selections": "Au maximum",
"max_value": "Est inférieur à",
"min_length": "Est plus long que",
"min_selections": "Au moins",
"min_value": "Est supérieur à",
"options_selected": "options sélectionnées",
"pattern": "Correspond au modèle d'expression régulière",
"phone": "Est un numéro de téléphone valide",
"required": "Est requis",
"url": "Est une URL valide"
},
"validation_rules": "Règles de validation",
"validation_rules_description": "Accepter uniquement les réponses qui répondent aux critères suivants",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" est utilisée dans le quota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "Le nom de la variable est déjà pris, veuillez en choisir un autre.",
@@ -1690,6 +1702,22 @@
"url_encryption_description": "Désactiver seulement si vous devez définir un identifiant unique personnalisé",
"url_encryption_label": "Cryptage de l'identifiant à usage unique dans l'URL"
},
"custom_html": {
"add_mode_description": "Les scripts de l'enquête s'exécuteront en plus des scripts au niveau de l'espace de travail.",
"add_to_workspace": "Ajouter aux scripts de l'espace de travail",
"description": "Ajouter des scripts de suivi et des pixels à cette enquête",
"nav_title": "HTML personnalisé",
"no_workspace_scripts": "Aucun script au niveau de l'espace de travail configuré. Vous pouvez les ajouter dans Paramètres de l'espace de travail → Général.",
"placeholder": "<!-- Collez vos scripts de suivi ici -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"replace_mode_description": "Seuls les scripts de l'enquête s'exécuteront. Les scripts de l'espace de travail seront ignorés. Laissez vide pour ne charger aucun script.",
"replace_workspace": "Remplacer les scripts de l'espace de travail",
"saved_successfully": "Scripts personnalisés enregistrés avec succès",
"script_mode": "Mode de script",
"security_warning": "Les scripts s'exécutent avec un accès complet au navigateur. Ajoutez uniquement des scripts provenant de sources fiables.",
"survey_scripts_description": "Ajouter du HTML personnalisé à injecter dans le <head> de cette page d'enquête.",
"survey_scripts_label": "Scripts spécifiques à l'enquête",
"workspace_scripts_label": "Scripts de l'espace de travail (hérités)"
},
"dynamic_popup": {
"alert_button": "Modifier enquête",
"alert_description": "Ce sondage est actuellement configuré comme un sondage de lien, qui ne prend pas en charge les pop-ups dynamiques. Vous pouvez le modifier dans l'onglet des paramètres de l'éditeur de sondage.",
@@ -1929,6 +1957,13 @@
},
"general": {
"cannot_delete_only_workspace": "Il s'agit de votre seul projet, il ne peut pas être supprimé. Créez d'abord un nouveau projet.",
"custom_scripts": "Scripts personnalisés",
"custom_scripts_card_description": "Ajouter des scripts de suivi et des pixels à toutes les enquêtes par lien dans cet espace de travail.",
"custom_scripts_description": "Les scripts seront injectés dans le <head> de toutes les pages d'enquête par lien.",
"custom_scripts_label": "Scripts HTML",
"custom_scripts_placeholder": "<!-- Collez vos scripts de suivi ici -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Scripts personnalisés mis à jour avec succès",
"custom_scripts_warning": "Les scripts s'exécutent avec un accès complet au navigateur. Ajoutez uniquement des scripts provenant de sources fiables.",
"delete_workspace": "Supprimer le projet",
"delete_workspace_confirmation": "Êtes-vous sûr de vouloir supprimer {projectName}? Cette action ne peut pas être annulée.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Supprimer {projectName} y compris toutes les enquêtes, réponses, personnes, actions et attributs.",
+40 -5
View File
@@ -238,7 +238,6 @@
"imprint": "企業情報",
"in_progress": "進行中",
"inactive_surveys": "非アクティブなフォーム",
"input_type": "入力タイプ",
"integration": "連携",
"integrations": "連携",
"invalid_date": "無効な日付です",
@@ -262,13 +261,11 @@
"look_and_feel": "デザイン",
"manage": "管理",
"marketing": "マーケティング",
"maximum": "最大",
"member": "メンバー",
"members": "メンバー",
"members_and_teams": "メンバー&チーム",
"membership_not_found": "メンバーシップが見つかりません",
"metadata": "メタデータ",
"minimum": "最小",
"mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。",
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
"mobile_overlay_title": "おっと、 小さな 画面 が 検出されました!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "フォームの質問の色を変更します。",
"changes_saved": "変更を保存しました。",
"changing_survey_type_will_remove_existing_distribution_channels": "フォームの種類を変更すると、共有方法に影響します。回答者が現在のタイプのアクセスリンクをすでに持っている場合、切り替え後にアクセスを失う可能性があります。",
"character_limit_toggle_description": "回答の長さの上限・下限を設定します。",
"character_limit_toggle_title": "文字数制限を追加",
"checkbox_label": "チェックボックスのラベル",
"choose_the_actions_which_trigger_the_survey": "フォームをトリガーするアクションを選択してください。",
"choose_the_first_question_on_your_block": "ブロックの最初の質問を選択してください",
@@ -1589,6 +1584,23 @@
"upper_label": "上限ラベル",
"url_filters": "URLフィルター",
"url_not_supported": "URLはサポートされていません",
"validation": {
"characters": "文字",
"email": "有効なメールアドレスである",
"max_length": "より短い",
"max_selections": "最大",
"max_value": "より小さい",
"min_length": "より長い",
"min_selections": "最小",
"min_value": "より大きい",
"options_selected": "個のオプションが選択されている",
"pattern": "正規表現パターンに一致する",
"phone": "有効な電話番号である",
"required": "必須である",
"url": "有効なURLである"
},
"validation_rules": "検証ルール",
"validation_rules_description": "次の条件を満たす回答のみを受け付ける",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "変数 \"{variableName}\" は \"{quotaName}\" クォータ で使用されています",
"variable_name_is_already_taken_please_choose_another": "変数名はすでに使用されています。別の名前を選択してください。",
@@ -1690,6 +1702,22 @@
"url_encryption_description": "カスタムの単一使用IDを設定する必要がある場合にのみ無効にしてください。",
"url_encryption_label": "単一使用IDのURL暗号化"
},
"custom_html": {
"add_mode_description": "アンケートスクリプトは、ワークスペースレベルのスクリプトに加えて実行されます。",
"add_to_workspace": "ワークスペーススクリプトに追加",
"description": "このアンケートにトラッキングスクリプトとピクセルを追加",
"nav_title": "カスタムHTML",
"no_workspace_scripts": "ワークスペースレベルのスクリプトが設定されていません。ワークスペース設定→一般から追加できます。",
"placeholder": "<!-- トラッキングスクリプトをここに貼り付けてください -->\n<script>\n // Google Tag Manager、Analyticsなど\n</script>",
"replace_mode_description": "アンケートスクリプトのみが実行されます。ワークスペーススクリプトは無視されます。スクリプトを読み込まない場合は空のままにしてください。",
"replace_workspace": "ワークスペーススクリプトを置き換え",
"saved_successfully": "カスタムスクリプトを正常に保存しました",
"script_mode": "スクリプトモード",
"security_warning": "スクリプトはブラウザへの完全なアクセス権で実行されます。信頼できるソースからのスクリプトのみを追加してください。",
"survey_scripts_description": "このアンケートページの<head>に挿入するカスタムHTMLを追加します。",
"survey_scripts_label": "アンケート固有のスクリプト",
"workspace_scripts_label": "ワークスペーススクリプト(継承)"
},
"dynamic_popup": {
"alert_button": "フォームを編集",
"alert_description": "このフォームは現在、動的なポップアップをサポートしていないリンクフォームとして設定されています。フォームエディターの設定タブでこれを変更できます。",
@@ -1929,6 +1957,13 @@
},
"general": {
"cannot_delete_only_workspace": "これは唯一のワークスペースのため、削除できません。まず新しいワークスペースを作成してください。",
"custom_scripts": "カスタムスクリプト",
"custom_scripts_card_description": "このワークスペース内のすべてのリンクアンケートにトラッキングスクリプトとピクセルを追加します。",
"custom_scripts_description": "すべてのリンクアンケートページの<head>にスクリプトが挿入されます。",
"custom_scripts_label": "HTMLスクリプト",
"custom_scripts_placeholder": "<!-- トラッキングスクリプトをここに貼り付けてください -->\n<script>\n // Google Tag Manager、Analyticsなど\n</script>",
"custom_scripts_updated_successfully": "カスタムスクリプトを正常に更新しました",
"custom_scripts_warning": "スクリプトはブラウザへの完全なアクセス権で実行されます。信頼できるソースからのスクリプトのみを追加してください。",
"delete_workspace": "ワークスペースを削除",
"delete_workspace_confirmation": "{projectName}を削除してもよろしいですか?このアクションは元に戻せません。",
"delete_workspace_name_includes_surveys_responses_people_and_more": "{projectName}をすべてのフォーム、回答、人物、アクション、属性を含めて削除します。",
+40 -5
View File
@@ -238,7 +238,6 @@
"imprint": "Afdruk",
"in_progress": "In uitvoering",
"inactive_surveys": "Inactieve enquêtes",
"input_type": "Invoertype",
"integration": "integratie",
"integrations": "Integraties",
"invalid_date": "Ongeldige datum",
@@ -262,13 +261,11 @@
"look_and_feel": "Kijk & voel",
"manage": "Beheren",
"marketing": "Marketing",
"maximum": "Maximaal",
"member": "Lid",
"members": "Leden",
"members_and_teams": "Leden & teams",
"membership_not_found": "Lidmaatschap niet gevonden",
"metadata": "Metagegevens",
"minimum": "Minimum",
"mobile_overlay_app_works_best_on_desktop": "Formbricks werkt het beste op een groter scherm. Schakel over naar een ander apparaat om enquêtes te beheren of samen te stellen.",
"mobile_overlay_surveys_look_good": "Maakt u zich geen zorgen: uw enquêtes zien er geweldig uit op elk apparaat en schermformaat!",
"mobile_overlay_title": "Oeps, klein scherm gedetecteerd!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "Verander de vraagkleur van de enquête.",
"changes_saved": "Wijzigingen opgeslagen.",
"changing_survey_type_will_remove_existing_distribution_channels": "Het wijzigen van het enquêtetype heeft invloed op de manier waarop deze kan worden gedeeld. Als respondenten al toegangslinks hebben voor het huidige type, verliezen ze mogelijk de toegang na de overstap.",
"character_limit_toggle_description": "Beperk hoe kort of lang een antwoord mag zijn.",
"character_limit_toggle_title": "Tekenlimieten toevoegen",
"checkbox_label": "Selectievakje-label",
"choose_the_actions_which_trigger_the_survey": "Kies de acties die de enquête activeren.",
"choose_the_first_question_on_your_block": "Kies de eerste vraag in je blok",
@@ -1589,6 +1584,23 @@
"upper_label": "Bovenste etiket",
"url_filters": "URL-filters",
"url_not_supported": "URL niet ondersteund",
"validation": {
"characters": "tekens",
"email": "Is geldig e-mailadres",
"max_length": "Is korter dan",
"max_selections": "Maximaal",
"max_value": "Is minder dan",
"min_length": "Is langer dan",
"min_selections": "Minimaal",
"min_value": "Is groter dan",
"options_selected": "opties geselecteerd",
"pattern": "Komt overeen met regex-patroon",
"phone": "Is geldig telefoonnummer",
"required": "Is verplicht",
"url": "Is geldige URL"
},
"validation_rules": "Validatieregels",
"validation_rules_description": "Accepteer alleen antwoorden die voldoen aan de volgende criteria",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabele \"{variableName}\" wordt gebruikt in het \"{quotaName}\" quotum",
"variable_name_is_already_taken_please_choose_another": "Variabelenaam is al in gebruik, kies een andere.",
@@ -1690,6 +1702,22 @@
"url_encryption_description": "Schakel dit alleen uit als u een aangepaste ID voor eenmalig gebruik moet instellen.",
"url_encryption_label": "URL-codering van ID voor eenmalig gebruik"
},
"custom_html": {
"add_mode_description": "Enquêtescripts worden uitgevoerd naast scripts op werkruimteniveau.",
"add_to_workspace": "Toevoegen aan werkruimtescripts",
"description": "Voeg trackingscripts en pixels toe aan deze enquête",
"nav_title": "Aangepaste HTML",
"no_workspace_scripts": "Geen scripts op werkruimteniveau geconfigureerd. Je kunt ze toevoegen in Werkruimte-instellingen → Algemeen.",
"placeholder": "<!-- Plak hier je trackingscripts -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"replace_mode_description": "Alleen enquêtescripts worden uitgevoerd. Werkruimtescripts worden genegeerd. Laat leeg om geen scripts te laden.",
"replace_workspace": "Werkruimtescripts vervangen",
"saved_successfully": "Aangepaste scripts succesvol opgeslagen",
"script_mode": "Scriptmodus",
"security_warning": "Scripts worden uitgevoerd met volledige browsertoegang. Voeg alleen scripts toe van vertrouwde bronnen.",
"survey_scripts_description": "Voeg aangepaste HTML toe om te injecteren in de <head> van deze enquêtepagina.",
"survey_scripts_label": "Enquêtespecifieke scripts",
"workspace_scripts_label": "Werkruimtescripts (overgenomen)"
},
"dynamic_popup": {
"alert_button": "Enquête bewerken",
"alert_description": "Deze enquête is momenteel geconfigureerd als een linkenquête, die geen dynamische pop-ups ondersteunt. U kunt dit wijzigen op het tabblad Instellingen van de enquête-editor.",
@@ -1929,6 +1957,13 @@
},
"general": {
"cannot_delete_only_workspace": "Dit is uw enige project, het kan niet worden verwijderd. Maak eerst een nieuw project aan.",
"custom_scripts": "Aangepaste scripts",
"custom_scripts_card_description": "Voeg trackingscripts en pixels toe aan alle linkenquêtes in deze werkruimte.",
"custom_scripts_description": "Scripts worden geïnjecteerd in de <head> van alle linkenquêtepagina's.",
"custom_scripts_label": "HTML-scripts",
"custom_scripts_placeholder": "<!-- Plak hier je trackingscripts -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Aangepaste scripts succesvol bijgewerkt",
"custom_scripts_warning": "Scripts worden uitgevoerd met volledige browsertoegang. Voeg alleen scripts toe van vertrouwde bronnen.",
"delete_workspace": "Project verwijderen",
"delete_workspace_confirmation": "Weet u zeker dat u {projectName} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Verwijder {projectName} incl. alle enquêtes, reacties, mensen, acties en attributen.",
+40 -5
View File
@@ -238,7 +238,6 @@
"imprint": "impressão",
"in_progress": "Em andamento",
"inactive_surveys": "Pesquisas inativas",
"input_type": "Tipo de entrada",
"integration": "integração",
"integrations": "Integrações",
"invalid_date": "Data inválida",
@@ -262,13 +261,11 @@
"look_and_feel": "Aparência e Experiência",
"manage": "gerenciar",
"marketing": "marketing",
"maximum": "Máximo",
"member": "Membros",
"members": "Membros",
"members_and_teams": "Membros e equipes",
"membership_not_found": "Assinatura não encontrada",
"metadata": "metadados",
"minimum": "Mínimo",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor em uma tela maior. Para gerenciar ou criar pesquisas, mude para outro dispositivo.",
"mobile_overlay_surveys_look_good": "Não se preocupe suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!",
"mobile_overlay_title": "Eita, tela pequena detectada!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "Muda a cor da pergunta da pesquisa.",
"changes_saved": "Mudanças salvas.",
"changing_survey_type_will_remove_existing_distribution_channels": "Alterar o tipo de pesquisa afetará a forma como ela pode ser compartilhada. Se os respondentes já tiverem links de acesso para o tipo atual, podem perder o acesso após a mudança.",
"character_limit_toggle_description": "Limite o quão curta ou longa uma resposta pode ser.",
"character_limit_toggle_title": "Adicionar limites de caracteres",
"checkbox_label": "Rótulo da Caixa de Seleção",
"choose_the_actions_which_trigger_the_survey": "Escolha as ações que disparam a pesquisa.",
"choose_the_first_question_on_your_block": "Escolha a primeira pergunta do seu bloco",
@@ -1589,6 +1584,23 @@
"upper_label": "Etiqueta Superior",
"url_filters": "Filtros de URL",
"url_not_supported": "URL não suportada",
"validation": {
"characters": "caracteres",
"email": "É um e-mail válido",
"max_length": "É menor que",
"max_selections": "No máximo",
"max_value": "É menor que",
"min_length": "É maior que",
"min_selections": "No mínimo",
"min_value": "É maior que",
"options_selected": "opções selecionadas",
"pattern": "Corresponde ao padrão regex",
"phone": "É um telefone válido",
"required": "É obrigatório",
"url": "É uma URL válida"
},
"validation_rules": "Regras de validação",
"validation_rules_description": "Aceitar apenas respostas que atendam aos seguintes critérios",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} está sendo usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está sendo usada na cota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
@@ -1690,6 +1702,22 @@
"url_encryption_description": "Desative apenas se precisar definir um ID de uso único personalizado",
"url_encryption_label": "Criptografia de URL de ID de uso único"
},
"custom_html": {
"add_mode_description": "Os scripts da pesquisa serão executados além dos scripts do nível do workspace.",
"add_to_workspace": "Adicionar aos scripts do workspace",
"description": "Adicione scripts de rastreamento e pixels a esta pesquisa",
"nav_title": "HTML personalizado",
"no_workspace_scripts": "Nenhum script de nível de workspace configurado. Você pode adicioná-los em Configurações do Workspace → Geral.",
"placeholder": "<!-- Cole seus scripts de rastreamento aqui -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"replace_mode_description": "Apenas os scripts da pesquisa serão executados. Os scripts do workspace serão ignorados. Deixe vazio para não carregar nenhum script.",
"replace_workspace": "Substituir scripts do workspace",
"saved_successfully": "Scripts personalizados salvos com sucesso",
"script_mode": "Modo de script",
"security_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes confiáveis.",
"survey_scripts_description": "Adicione HTML personalizado para injetar no <head> desta página de pesquisa.",
"survey_scripts_label": "Scripts específicos da pesquisa",
"workspace_scripts_label": "Scripts do workspace (herdados)"
},
"dynamic_popup": {
"alert_button": "Editar pesquisa",
"alert_description": "Esta pesquisa está atualmente configurada como uma pesquisa de link, o que não suporta pop-ups dinâmicos. Você pode alterar isso na aba de configurações do editor de pesquisas.",
@@ -1929,6 +1957,13 @@
},
"general": {
"cannot_delete_only_workspace": "Este é seu único projeto, ele não pode ser excluído. Crie um novo projeto primeiro.",
"custom_scripts": "Scripts personalizados",
"custom_scripts_card_description": "Adicione scripts de rastreamento e pixels a todas as pesquisas de link neste workspace.",
"custom_scripts_description": "Os scripts serão injetados no <head> de todas as páginas de pesquisa de link.",
"custom_scripts_label": "Scripts HTML",
"custom_scripts_placeholder": "<!-- Cole seus scripts de rastreamento aqui -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Scripts personalizados atualizados com sucesso",
"custom_scripts_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes confiáveis.",
"delete_workspace": "Excluir projeto",
"delete_workspace_confirmation": "Tem certeza de que deseja excluir {projectName}? Essa ação não pode ser desfeita.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Excluir {projectName} incluindo todas as pesquisas, respostas, pessoas, ações e atributos.",
+40 -5
View File
@@ -238,7 +238,6 @@
"imprint": "Impressão",
"in_progress": "Em Progresso",
"inactive_surveys": "Inquéritos inativos",
"input_type": "Tipo de entrada",
"integration": "integração",
"integrations": "Integrações",
"invalid_date": "Data inválida",
@@ -262,13 +261,11 @@
"look_and_feel": "Aparência e Sensação",
"manage": "Gerir",
"marketing": "Marketing",
"maximum": "Máximo",
"member": "Membro",
"members": "Membros",
"members_and_teams": "Membros e equipas",
"membership_not_found": "Associação não encontrada",
"metadata": "Metadados",
"minimum": "Mínimo",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor num ecrã maior. Para gerir ou criar inquéritos, mude de dispositivo.",
"mobile_overlay_surveys_look_good": "Não se preocupe os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!",
"mobile_overlay_title": "Oops, ecrã pequeno detectado!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "Alterar a cor da pergunta do inquérito",
"changes_saved": "Alterações guardadas.",
"changing_survey_type_will_remove_existing_distribution_channels": "Alterar o tipo de inquérito afetará como ele pode ser partilhado. Se os respondentes já tiverem links de acesso para o tipo atual, podem perder o acesso após a mudança.",
"character_limit_toggle_description": "Limitar o quão curta ou longa uma resposta pode ser.",
"character_limit_toggle_title": "Adicionar limites de caracteres",
"checkbox_label": "Rótulo da Caixa de Seleção",
"choose_the_actions_which_trigger_the_survey": "Escolha as ações que desencadeiam o inquérito.",
"choose_the_first_question_on_your_block": "Escolha a primeira pergunta no seu bloco",
@@ -1589,6 +1584,23 @@
"upper_label": "Etiqueta Superior",
"url_filters": "Filtros de URL",
"url_not_supported": "URL não suportado",
"validation": {
"characters": "caracteres",
"email": "É um email válido",
"max_length": "É mais curto que",
"max_selections": "No máximo",
"max_value": "É menos que",
"min_length": "É mais longo que",
"min_selections": "Pelo menos",
"min_value": "É maior que",
"options_selected": "opções selecionadas",
"pattern": "Coincide com o padrão regex",
"phone": "É um telefone válido",
"required": "É obrigatório",
"url": "É um URL válido"
},
"validation_rules": "Regras de validação",
"validation_rules_description": "Aceitar apenas respostas que cumpram os seguintes critérios",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está a ser utilizada na quota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
@@ -1690,6 +1702,22 @@
"url_encryption_description": "Desative apenas se precisar definir um ID de uso único personalizado.",
"url_encryption_label": "Encriptação do URL de ID de uso único"
},
"custom_html": {
"add_mode_description": "Os scripts do inquérito serão executados para além dos scripts ao nível da área de trabalho.",
"add_to_workspace": "Adicionar aos scripts da área de trabalho",
"description": "Adicionar scripts de rastreamento e pixels a este inquérito",
"nav_title": "HTML personalizado",
"no_workspace_scripts": "Nenhum script ao nível da área de trabalho configurado. Pode adicioná-los em Definições da Área de Trabalho → Geral.",
"placeholder": "<!-- Cole os seus scripts de rastreamento aqui -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"replace_mode_description": "Apenas os scripts do inquérito serão executados. Os scripts da área de trabalho serão ignorados. Deixe vazio para não carregar nenhum script.",
"replace_workspace": "Substituir scripts da área de trabalho",
"saved_successfully": "Scripts personalizados guardados com sucesso",
"script_mode": "Modo de script",
"security_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes fidedignas.",
"survey_scripts_description": "Adicionar HTML personalizado para injetar no <head> desta página de inquérito.",
"survey_scripts_label": "Scripts específicos do inquérito",
"workspace_scripts_label": "Scripts da área de trabalho (herdados)"
},
"dynamic_popup": {
"alert_button": "Editar inquérito",
"alert_description": "Este questionário está atualmente configurado como um questionário de link, que não suporta pop-ups dinâmicos. Você pode alterar isso na aba de configurações do editor de questionários.",
@@ -1929,6 +1957,13 @@
},
"general": {
"cannot_delete_only_workspace": "Este é o seu único projeto, não pode ser eliminado. Crie primeiro um novo projeto.",
"custom_scripts": "Scripts personalizados",
"custom_scripts_card_description": "Adicionar scripts de rastreamento e pixels a todos os inquéritos de link nesta área de trabalho.",
"custom_scripts_description": "Os scripts serão injetados no <head> de todas as páginas de inquéritos de link.",
"custom_scripts_label": "Scripts HTML",
"custom_scripts_placeholder": "<!-- Cole os seus scripts de rastreamento aqui -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Scripts personalizados atualizados com sucesso",
"custom_scripts_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes fidedignas.",
"delete_workspace": "Eliminar projeto",
"delete_workspace_confirmation": "Tem a certeza de que pretende eliminar {projectName}? Esta ação não pode ser desfeita.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Eliminar {projectName} incluindo todos os inquéritos, respostas, pessoas, ações e atributos.",
+40 -5
View File
@@ -238,7 +238,6 @@
"imprint": "Amprentă",
"in_progress": "În progres",
"inactive_surveys": "Sondaje inactive",
"input_type": "Tipul de intrare",
"integration": "integrare",
"integrations": "Integrări",
"invalid_date": "Dată invalidă",
@@ -262,13 +261,11 @@
"look_and_feel": "Aspect și Comportament",
"manage": "Gestionați",
"marketing": "Marketing",
"maximum": "Maximum",
"member": "Membru",
"members": "Membri",
"members_and_teams": "Membri și echipe",
"membership_not_found": "Apartenența nu a fost găsită",
"metadata": "Metadate",
"minimum": "Minim",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funcționează cel mai bine pe un ecran mai mare. Pentru a gestiona sau crea chestionare, treceți la un alt dispozitiv.",
"mobile_overlay_surveys_look_good": "Nu vă faceți griji chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!",
"mobile_overlay_title": "Ups, ecran mic detectat!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "Schimbați culoarea întrebării chestionarului.",
"changes_saved": "Modificările au fost salvate",
"changing_survey_type_will_remove_existing_distribution_channels": "Schimbarea tipului chestionarului va afecta modul în care acesta poate fi distribuit. Dacă respondenții au deja linkuri de acces pentru tipul curent, aceștia ar putea pierde accesul după schimbare.",
"character_limit_toggle_description": "Limitați cât de scurt sau lung poate fi un răspuns.",
"character_limit_toggle_title": "Adăugați limite de caractere",
"checkbox_label": "Etichetă casetă de selectare",
"choose_the_actions_which_trigger_the_survey": "Alegeți acțiunile care declanșează sondajul.",
"choose_the_first_question_on_your_block": "Alege prima întrebare din blocul tău",
@@ -1589,6 +1584,23 @@
"upper_label": "Etichetă superioară",
"url_filters": "Filtre URL",
"url_not_supported": "URL nesuportat",
"validation": {
"characters": "caractere",
"email": "Este un email valid",
"max_length": "Este mai scurt de",
"max_selections": "Cel mult",
"max_value": "Este mai mic decât",
"min_length": "Este mai lung de",
"min_selections": "Cel puțin",
"min_value": "Este mai mare decât",
"options_selected": "opțiuni selectate",
"pattern": "Se potrivește cu un șablon regex",
"phone": "Este un număr de telefon valid",
"required": "Este obligatoriu",
"url": "Este un URL valid"
},
"validation_rules": "Reguli de validare",
"validation_rules_description": "Acceptă doar răspunsurile care îndeplinesc următoarele criterii",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} este folosit în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabila \"{variableName}\" este folosită în cota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "Numele variabilei este deja utilizat, vă rugăm să alegeți altul.",
@@ -1690,6 +1702,22 @@
"url_encryption_description": "Dezactivați doar dacă trebuie să setați un ID unic personalizat.",
"url_encryption_label": "Criptarea URL pentru ID unic de utilizare"
},
"custom_html": {
"add_mode_description": "Scripturile sondajului vor rula în plus față de scripturile la nivel de spațiu de lucru.",
"add_to_workspace": "Adaugă la scripturile spațiului de lucru",
"description": "Adaugă scripturi de tracking și pixeli acestui sondaj",
"nav_title": "HTML personalizat",
"no_workspace_scripts": "Nu există scripturi la nivel de spațiu de lucru configurate. Le poți adăuga în Setări spațiu de lucru → General.",
"placeholder": "<!-- Lipește aici scripturile tale de tracking -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"replace_mode_description": "Vor rula doar scripturile sondajului. Scripturile spațiului de lucru vor fi ignorate. Lasă gol pentru a nu încărca niciun script.",
"replace_workspace": "Înlocuiește scripturile spațiului de lucru",
"saved_successfully": "Scripturile personalizate au fost salvate cu succes",
"script_mode": "Modul script",
"security_warning": "Scripturile se execută cu acces complet la browser. Adaugă doar scripturi din surse de încredere.",
"survey_scripts_description": "Adaugă HTML personalizat pentru a fi injectat în <head> pe această pagină de sondaj.",
"survey_scripts_label": "Scripturi specifice sondajului",
"workspace_scripts_label": "Scripturi spațiu de lucru (moștenite)"
},
"dynamic_popup": {
"alert_button": "Editează chestionar",
"alert_description": "Acest sondaj este configurat în prezent ca un sondaj cu link, care nu suportă pop-up-uri dinamice. Puteți schimba acest lucru în fila de setări a editorului de sondaje.",
@@ -1929,6 +1957,13 @@
},
"general": {
"cannot_delete_only_workspace": "Acesta este singurul tău proiect, nu poate fi șters. Creează mai întâi un proiect nou.",
"custom_scripts": "Scripturi personalizate",
"custom_scripts_card_description": "Adaugă scripturi de tracking și pixeli tuturor sondajelor cu link din acest spațiu de lucru.",
"custom_scripts_description": "Scripturile vor fi injectate în <head> pe toate paginile sondajelor cu link.",
"custom_scripts_label": "Scripturi HTML",
"custom_scripts_placeholder": "<!-- Lipește aici scripturile tale de tracking -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Scripturile personalizate au fost actualizate cu succes",
"custom_scripts_warning": "Scripturile se execută cu acces complet la browser. Adaugă doar scripturi din surse de încredere.",
"delete_workspace": "Șterge proiectul",
"delete_workspace_confirmation": "Sigur vrei să ștergi {projectName}? Această acțiune nu poate fi anulată.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Șterge {projectName} incl. toate sondajele, răspunsurile, persoanele, acțiunile și atributele.",
+40 -5
View File
@@ -238,7 +238,6 @@
"imprint": "Выходные данные",
"in_progress": "В процессе",
"inactive_surveys": "Неактивные опросы",
"input_type": "Тип ввода",
"integration": "интеграция",
"integrations": "Интеграции",
"invalid_date": "Неверная дата",
@@ -262,13 +261,11 @@
"look_and_feel": "Внешний вид",
"manage": "Управление",
"marketing": "Маркетинг",
"maximum": "Максимум",
"member": "Участник",
"members": "Участники",
"members_and_teams": "Участники и команды",
"membership_not_found": "Участие не найдено",
"metadata": "Метаданные",
"minimum": "Минимум",
"mobile_overlay_app_works_best_on_desktop": "Formbricks лучше всего работает на большом экране. Для управления или создания опросов перейдите на другое устройство.",
"mobile_overlay_surveys_look_good": "Не волнуйтесь — ваши опросы отлично выглядят на любом устройстве и экране!",
"mobile_overlay_title": "Ой, обнаружен маленький экран!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "Изменить цвет вопросов в опросе.",
"changes_saved": "Изменения сохранены.",
"changing_survey_type_will_remove_existing_distribution_channels": "Изменение типа опроса повлияет на способы его распространения. Если у респондентов уже есть ссылки для доступа к текущему типу, после смены они могут потерять доступ.",
"character_limit_toggle_description": "Ограничьте минимальную и максимальную длину ответа.",
"character_limit_toggle_title": "Добавить ограничения на количество символов",
"checkbox_label": "Метка флажка",
"choose_the_actions_which_trigger_the_survey": "Выберите действия, которые запускают опрос.",
"choose_the_first_question_on_your_block": "Выберите первый вопрос в вашем блоке",
@@ -1589,6 +1584,23 @@
"upper_label": "Верхняя метка",
"url_filters": "Фильтры URL",
"url_not_supported": "URL не поддерживается",
"validation": {
"characters": "символов",
"email": "Корректный email",
"max_length": "Короче чем",
"max_selections": "Не более",
"max_value": "Меньше чем",
"min_length": "Длиннее чем",
"min_selections": "Не менее",
"min_value": "Больше чем",
"options_selected": "выбрано вариантов",
"pattern": "Соответствует шаблону regex",
"phone": "Корректный телефон",
"required": "Обязательное поле",
"url": "Корректный URL"
},
"validation_rules": "Правила валидации",
"validation_rules_description": "Принимать только ответы, соответствующие следующим критериям",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите его из логики.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Переменная «{variableName}» используется в квоте «{quotaName}»",
"variable_name_is_already_taken_please_choose_another": "Это имя переменной уже занято, выберите другое.",
@@ -1690,6 +1702,22 @@
"url_encryption_description": "Отключайте только если нужно задать собственный одноразовый ID.",
"url_encryption_label": "Шифрование URL для одноразового ID"
},
"custom_html": {
"add_mode_description": "Скрипты опроса будут выполняться дополнительно к скриптам на уровне рабочего пространства.",
"add_to_workspace": "Добавить к скриптам рабочего пространства",
"description": "Добавьте трекинговые скрипты и пиксели в этот опрос",
"nav_title": "Пользовательский HTML",
"no_workspace_scripts": "Скрипты на уровне рабочего пространства не настроены. Вы можете добавить их в настройках рабочего пространства → Общие.",
"placeholder": "<!-- Вставьте сюда ваши трекинговые скрипты -->\n<script>\n // Google Tag Manager, Analytics и др.\n</script>",
"replace_mode_description": "Будут выполняться только скрипты опроса. Скрипты рабочего пространства будут проигнорированы. Оставьте пустым, чтобы не загружать скрипты.",
"replace_workspace": "Заменить скрипты рабочего пространства",
"saved_successfully": "Пользовательские скрипты успешно сохранены",
"script_mode": "Режим скриптов",
"security_warning": "Скрипты выполняются с полным доступом к браузеру. Добавляйте только скрипты из доверенных источников.",
"survey_scripts_description": "Добавьте пользовательский HTML для внедрения в <head> этой страницы опроса.",
"survey_scripts_label": "Скрипты, специфичные для опроса",
"workspace_scripts_label": "Скрипты рабочего пространства (унаследованные)"
},
"dynamic_popup": {
"alert_button": "Редактировать опрос",
"alert_description": "Этот опрос сейчас настроен как опрос по ссылке, что не поддерживает динамические pop-up окна. Вы можете изменить это на вкладке настроек редактора опроса.",
@@ -1929,6 +1957,13 @@
},
"general": {
"cannot_delete_only_workspace": "Это ваш единственный рабочий проект, его нельзя удалить. Сначала создайте новый проект.",
"custom_scripts": "Пользовательские скрипты",
"custom_scripts_card_description": "Добавьте трекинговые скрипты и пиксели ко всем опросам по ссылке в этом рабочем пространстве.",
"custom_scripts_description": "Скрипты будут внедряться в <head> всех страниц опросов по ссылке.",
"custom_scripts_label": "HTML-скрипты",
"custom_scripts_placeholder": "<!-- Вставьте сюда ваши трекинговые скрипты -->\n<script>\n // Google Tag Manager, Analytics и др.\n</script>",
"custom_scripts_updated_successfully": "Пользовательские скрипты успешно обновлены",
"custom_scripts_warning": "Скрипты выполняются с полным доступом к браузеру. Добавляйте только скрипты из доверенных источников.",
"delete_workspace": "Удалить рабочий проект",
"delete_workspace_confirmation": "Вы уверены, что хотите удалить {projectName}? Это действие необратимо.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Удалить {projectName} вместе со всеми опросами, ответами, пользователями, действиями и атрибутами.",
+40 -5
View File
@@ -238,7 +238,6 @@
"imprint": "Impressum",
"in_progress": "Pågående",
"inactive_surveys": "Inaktiva enkäter",
"input_type": "Inmatningstyp",
"integration": "integration",
"integrations": "Integrationer",
"invalid_date": "Ogiltigt datum",
@@ -262,13 +261,11 @@
"look_and_feel": "Utseende",
"manage": "Hantera",
"marketing": "Marknadsföring",
"maximum": "Maximum",
"member": "Medlem",
"members": "Medlemmar",
"members_and_teams": "Medlemmar och team",
"membership_not_found": "Medlemskap hittades inte",
"metadata": "Metadata",
"minimum": "Minimum",
"mobile_overlay_app_works_best_on_desktop": "Formbricks fungerar bäst på en större skärm. Byt till en annan enhet för att hantera eller bygga enkäter.",
"mobile_overlay_surveys_look_good": "Oroa dig inte dina enkäter ser bra ut på alla enheter och skärmstorlekar!",
"mobile_overlay_title": "Hoppsan, liten skärm upptäckt!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "Ändra enkätens frågefärg.",
"changes_saved": "Ändringar sparade.",
"changing_survey_type_will_remove_existing_distribution_channels": "Att ändra enkättypen påverkar hur den kan delas. Om respondenter redan har åtkomstlänkar för den nuvarande typen kan de förlora åtkomst efter bytet.",
"character_limit_toggle_description": "Begränsa hur kort eller långt ett svar kan vara.",
"character_limit_toggle_title": "Lägg till teckengränser",
"checkbox_label": "Kryssruteetikett",
"choose_the_actions_which_trigger_the_survey": "Välj de åtgärder som utlöser enkäten.",
"choose_the_first_question_on_your_block": "Välj den första frågan i ditt block",
@@ -1589,6 +1584,23 @@
"upper_label": "Övre etikett",
"url_filters": "URL-filter",
"url_not_supported": "URL stöds inte",
"validation": {
"characters": "tecken",
"email": "Är en giltig e-postadress",
"max_length": "Är kortare än",
"max_selections": "Högst",
"max_value": "Är mindre än",
"min_length": "Är längre än",
"min_selections": "Minst",
"min_value": "Är större än",
"options_selected": "valda alternativ",
"pattern": "Matchar regexmönster",
"phone": "Är ett giltigt telefonnummer",
"required": "Är obligatorisk",
"url": "Är en giltig URL"
},
"validation_rules": "Valideringsregler",
"validation_rules_description": "Acceptera endast svar som uppfyller följande kriterier",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabel \"{variableName}\" används i kvoten \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "Variabelnamnet är redan taget, vänligen välj ett annat.",
@@ -1690,6 +1702,22 @@
"url_encryption_description": "Inaktivera endast om du behöver ange ett anpassat engångs-ID.",
"url_encryption_label": "URL-kryptering av engångs-ID"
},
"custom_html": {
"add_mode_description": "Undersökningsskript kommer att köras utöver arbetsytans skript.",
"add_to_workspace": "Lägg till i arbetsytans skript",
"description": "Lägg till spårningsskript och pixlar i denna undersökning",
"nav_title": "Anpassad HTML",
"no_workspace_scripts": "Inga arbetsytans skript har konfigurerats. Du kan lägga till dem i Arbetsytans inställningar → Allmänt.",
"placeholder": "<!-- Klistra in dina spårningsskript här -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"replace_mode_description": "Endast undersökningsskript kommer att köras. Arbetsytans skript ignoreras. Lämna tomt för att inte ladda några skript.",
"replace_workspace": "Ersätt arbetsytans skript",
"saved_successfully": "Anpassade skript har sparats",
"script_mode": "Skriptläge",
"security_warning": "Skript körs med full åtkomst till webbläsaren. Lägg endast till skript från betrodda källor.",
"survey_scripts_description": "Lägg till anpassad HTML för att injicera i <head> på denna undersökningssida.",
"survey_scripts_label": "Undersökningsspecifika skript",
"workspace_scripts_label": "Arbetsytans skript (ärvda)"
},
"dynamic_popup": {
"alert_button": "Redigera enkät",
"alert_description": "Denna enkät är för närvarande konfigurerad som en länkenkät, vilket inte stöder dynamiska popup-fönster. Du kan ändra detta i inställningsfliken i enkätredigeraren.",
@@ -1929,6 +1957,13 @@
},
"general": {
"cannot_delete_only_workspace": "Detta är din enda arbetsyta, den kan inte tas bort. Skapa först en ny arbetsyta.",
"custom_scripts": "Anpassade skript",
"custom_scripts_card_description": "Lägg till spårningsskript och pixlar i alla länkundersökningar i denna arbetsyta.",
"custom_scripts_description": "Skript kommer att injiceras i <head> på alla länkundersökningssidor.",
"custom_scripts_label": "HTML-skript",
"custom_scripts_placeholder": "<!-- Klistra in dina spårningsskript här -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Anpassade skript har uppdaterats",
"custom_scripts_warning": "Skript körs med full åtkomst till webbläsaren. Lägg endast till skript från betrodda källor.",
"delete_workspace": "Ta bort arbetsyta",
"delete_workspace_confirmation": "Är du säker på att du vill ta bort {projectName}? Denna åtgärd kan inte ångras.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Ta bort {projectName} inkl. alla enkäter, svar, personer, åtgärder och attribut.",
+40 -5
View File
@@ -238,7 +238,6 @@
"imprint": "印记",
"in_progress": "进行中",
"inactive_surveys": "不 活跃 调查",
"input_type": "输入类型",
"integration": "集成",
"integrations": "集成",
"invalid_date": "无效 日期",
@@ -262,13 +261,11 @@
"look_and_feel": "外观 & 感觉",
"manage": "管理",
"marketing": "市场营销",
"maximum": "最大值",
"member": "成员",
"members": "成员",
"members_and_teams": "成员和团队",
"membership_not_found": "未找到会员资格",
"metadata": "元数据",
"minimum": "最低",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 在 更大 的 屏幕 上 效果 最佳。 若 需要 管理 或 构建 调查, 请 切换 到 其他 设备。",
"mobile_overlay_surveys_look_good": "别 担心 – 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
"mobile_overlay_title": "噢, 检测 到 小 屏幕!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "更改调查的 问题颜色",
"changes_saved": "更改 已 保存",
"changing_survey_type_will_remove_existing_distribution_channels": "更改 调查 类型 会影 响 分享 方式 。 如果 受访者 已经 拥有 当前 类型 的 访问 链接 , 在 更改 之后 ,他们 可能 会 失去 访问 权限 。",
"character_limit_toggle_description": "限制 答案的短或长程度。",
"character_limit_toggle_title": "添加 字符限制",
"checkbox_label": "复选框 标签",
"choose_the_actions_which_trigger_the_survey": "选择 触发 调查 的 动作 。",
"choose_the_first_question_on_your_block": "选择区块中的第一个问题",
@@ -1589,6 +1584,23 @@
"upper_label": "上限标签",
"url_filters": "URL 过滤器",
"url_not_supported": "URL 不支持",
"validation": {
"characters": "个字符",
"email": "是有效的邮箱地址",
"max_length": "短于",
"max_selections": "最多",
"max_value": "小于",
"min_length": "长于",
"min_selections": "至少",
"min_value": "大于",
"options_selected": "项已选择",
"pattern": "匹配正则表达式模式",
"phone": "是有效的手机号",
"required": "为必填项",
"url": "是有效的URL"
},
"validation_rules": "校验规则",
"validation_rules_description": "仅接受符合以下条件的回复",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{variable} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "变量 \"{variableName}\" 正在 被 \"{quotaName}\" 配额 使用",
"variable_name_is_already_taken_please_choose_another": "变量名已被占用,请选择其他。",
@@ -1690,6 +1702,22 @@
"url_encryption_description": "仅在 需要 设置 自定义 单次使用 ID 时 才 禁用。",
"url_encryption_label": "单次 使用 ID 的 URL 加密"
},
"custom_html": {
"add_mode_description": "调查脚本将在工作区级脚本的基础上运行。",
"add_to_workspace": "添加到工作区脚本",
"description": "为此调查添加跟踪脚本和像素代码",
"nav_title": "自定义 HTML",
"no_workspace_scripts": "尚未配置工作区级脚本。你可以在工作区设置 → 常规中添加。",
"placeholder": "<!-- 在此粘贴你的跟踪脚本 -->\n<script>\n // Google Tag Manager、Analytics 等\n</script>",
"replace_mode_description": "仅运行调查脚本,工作区脚本将被忽略。保持为空则不加载任何脚本。",
"replace_workspace": "替换工作区脚本",
"saved_successfully": "自定义脚本保存成功",
"script_mode": "脚本模式",
"security_warning": "脚本将以完整浏览器权限执行。请仅添加来自可信来源的脚本。",
"survey_scripts_description": "添加自定义 HTML 注入到此调查页面的<head>中。",
"survey_scripts_label": "调查专用脚本",
"workspace_scripts_label": "工作区脚本(继承)"
},
"dynamic_popup": {
"alert_button": "编辑 survey",
"alert_description": "此 问卷 当前 配置 为 链接 问卷, 不 支持 动态 弹出 窗。 您 可以 在 问卷 编辑器 的 设置 选项 中 进行 修改。",
@@ -1929,6 +1957,13 @@
},
"general": {
"cannot_delete_only_workspace": "这是您唯一的工作区,无法删除。请先创建一个新工作区。",
"custom_scripts": "自定义脚本",
"custom_scripts_card_description": "为此工作区内所有链接调查添加跟踪脚本和像素代码。",
"custom_scripts_description": "脚本将被注入到所有链接调查页面的<head>中。",
"custom_scripts_label": "HTML 脚本",
"custom_scripts_placeholder": "<!-- 在此粘贴你的跟踪脚本 -->\n<script>\n // Google Tag Manager、Analytics 等\n</script>",
"custom_scripts_updated_successfully": "自定义脚本更新成功",
"custom_scripts_warning": "脚本将以完整浏览器权限执行。请仅添加来自可信来源的脚本。",
"delete_workspace": "删除工作区",
"delete_workspace_confirmation": "您确定要删除 {projectName} 吗?此操作无法撤销。",
"delete_workspace_name_includes_surveys_responses_people_and_more": "删除 {projectName},包括所有调查、回应、人员、动作和属性。",
+40 -5
View File
@@ -238,7 +238,6 @@
"imprint": "版本訊息",
"in_progress": "進行中",
"inactive_surveys": "停用中的問卷",
"input_type": "輸入類型",
"integration": "整合",
"integrations": "整合",
"invalid_date": "無效日期",
@@ -262,13 +261,11 @@
"look_and_feel": "外觀與風格",
"manage": "管理",
"marketing": "行銷",
"maximum": "最大值",
"member": "成員",
"members": "成員",
"members_and_teams": "成員與團隊",
"membership_not_found": "找不到成員資格",
"metadata": "元數據",
"minimum": "最小值",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 適合在大螢幕上使用。若要管理或建立問卷,請切換到其他裝置。",
"mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
"mobile_overlay_title": "糟糕 ,偵測到小螢幕!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "變更問卷的問題顏色。",
"changes_saved": "已儲存變更。",
"changing_survey_type_will_remove_existing_distribution_channels": "更改問卷類型會影響其共享方式。如果受訪者已擁有當前類型的存取連結,則在切換後可能會失去存取權限。",
"character_limit_toggle_description": "限制答案的長度或短度。",
"character_limit_toggle_title": "新增字元限制",
"checkbox_label": "核取方塊標籤",
"choose_the_actions_which_trigger_the_survey": "選擇觸發問卷的操作。",
"choose_the_first_question_on_your_block": "選擇此區塊的第一個問題",
@@ -1589,6 +1584,23 @@
"upper_label": "上標籤",
"url_filters": "網址篩選器",
"url_not_supported": "不支援網址",
"validation": {
"characters": "個字元",
"email": "是有效的電子郵件",
"max_length": "少於",
"max_selections": "最多",
"max_value": "小於",
"min_length": "多於",
"min_selections": "至少",
"min_value": "大於",
"options_selected": "個選項已選",
"pattern": "符合正則表達式樣式",
"phone": "是有效的電話號碼",
"required": "為必填",
"url": "是有效的 URL"
},
"validation_rules": "驗證規則",
"validation_rules_description": "僅接受符合下列條件的回應",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'variable'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "變數 \"{variableName}\" 正被使用於 \"{quotaName}\" 配額中",
"variable_name_is_already_taken_please_choose_another": "已使用此變數名稱,請選擇另一個名稱。",
@@ -1690,6 +1702,22 @@
"url_encryption_description": "僅在需要設定自訂一次性 ID 時停用",
"url_encryption_label": "單次使用 ID 的 URL 加密"
},
"custom_html": {
"add_mode_description": "調查問卷腳本將會與工作區層級的腳本一同執行。",
"add_to_workspace": "加入至工作區腳本",
"description": "將追蹤腳本與像素碼加入此調查問卷",
"nav_title": "自訂 HTML",
"no_workspace_scripts": "尚未設定工作區層級腳本。您可以在「工作區設定」→「一般」中新增。",
"placeholder": "<!-- 請在此貼上您的追蹤腳本 -->\n<script>\n // Google Tag Manager、Analytics 等\n</script>",
"replace_mode_description": "僅執行調查問卷腳本,將忽略工作區腳本。若不需載入任何腳本,請保持空白。",
"replace_workspace": "取代工作區腳本",
"saved_successfully": "自訂腳本已成功儲存",
"script_mode": "腳本模式",
"security_warning": "腳本將以完整瀏覽器權限執行。請僅加入來自可信來源的腳本。",
"survey_scripts_description": "新增自訂 HTML 以注入至此調查問卷頁面的 <head>。",
"survey_scripts_label": "調查問卷專屬腳本",
"workspace_scripts_label": "工作區腳本(繼承)"
},
"dynamic_popup": {
"alert_button": "編輯 問卷",
"alert_description": "此 問卷 目前 被 設定 為 連結 問卷,不 支援 動態 彈出窗口。您 可 在 問卷 編輯器 的 設定 標籤 中 進行 更改。",
@@ -1929,6 +1957,13 @@
},
"general": {
"cannot_delete_only_workspace": "這是您唯一的工作區,無法刪除。請先建立新的工作區。",
"custom_scripts": "自訂腳本",
"custom_scripts_card_description": "將追蹤腳本與像素碼加入此工作區內所有連結調查問卷。",
"custom_scripts_description": "腳本將注入至所有連結調查問卷頁面的 <head>。",
"custom_scripts_label": "HTML 腳本",
"custom_scripts_placeholder": "<!-- 請在此貼上您的追蹤腳本 -->\n<script>\n // Google Tag Manager、Analytics 等\n</script>",
"custom_scripts_updated_successfully": "自訂腳本已成功更新",
"custom_scripts_warning": "腳本將以完整瀏覽器權限執行。請僅加入來自可信來源的腳本。",
"delete_workspace": "刪除工作區",
"delete_workspace_confirmation": "您確定要刪除 {projectName} 嗎?此操作無法復原。",
"delete_workspace_name_includes_surveys_responses_people_and_more": "刪除 {projectName}(包含所有問卷、回應、人員、操作和屬性)。",
+17 -1
View File
@@ -1,4 +1,5 @@
import { ZodRawShape, z } from "zod";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { TApiAuditLog } from "@/app/lib/api/with-api-logging";
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
@@ -67,7 +68,22 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
let parsedInput: ParsedSchemas<S> = {} as ParsedSchemas<S>;
if (schemas?.body) {
const bodyData = await request.json();
let bodyData;
try {
bodyData = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input");
return handleApiError(request, {
type: "bad_request",
details: [
{
field: "error",
issue: "Malformed JSON input, please check your request body",
},
],
});
}
const bodyResult = schemas.body.safeParse(bodyData);
if (!bodyResult.success) {
@@ -132,6 +132,71 @@ describe("apiWrapper", () => {
expect(handler).not.toHaveBeenCalled();
});
test("should handle malformed JSON input in request body", async () => {
const request = new Request("http://localhost", {
method: "POST",
body: "{ invalid json }",
headers: { "Content-Type": "application/json" },
});
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const bodySchema = z.object({ key: z.string() });
const handler = vi.fn();
const response = await apiWrapper({
request,
schemas: { body: bodySchema },
rateLimit: false,
handler,
});
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
expect(handleApiError).toHaveBeenCalledWith(request, {
type: "bad_request",
details: [
{
field: "error",
issue: "Malformed JSON input, please check your request body",
},
],
});
});
test("should handle empty body when body schema is provided", async () => {
const request = new Request("http://localhost", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const bodySchema = z.object({ key: z.string() });
const handler = vi.fn();
const response = await apiWrapper({
request,
schemas: { body: bodySchema },
rateLimit: false,
handler,
});
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
expect(handleApiError).toHaveBeenCalledWith(request, {
type: "bad_request",
details: [
{
field: "error",
issue: "Malformed JSON input, please check your request body",
},
],
});
});
test("should parse query schema correctly", async () => {
const request = new Request("http://localhost?key=value");
@@ -2,7 +2,7 @@
import { debounce } from "lodash";
import dynamic from "next/dynamic";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TEnvironment } from "@formbricks/types/environment";
@@ -48,40 +48,40 @@ export const ContactDataView = ({
);
}, [contactAttributeKeys]);
// Fetch contacts from offset 0 with current search value
const fetchContactsFromStart = useCallback(async () => {
setIsDataLoaded(false);
try {
setHasMore(true);
const contactsResponse = await getContactsAction({
environmentId: environment.id,
offset: 0,
searchValue,
});
if (contactsResponse?.data) {
setContacts(contactsResponse.data);
}
if (contactsResponse?.data && contactsResponse.data.length < itemsPerPage) {
setHasMore(false);
}
} catch (error) {
console.error("Error fetching contacts:", error);
toast.error("Error fetching contacts. Please try again.");
} finally {
setIsDataLoaded(true);
}
}, [environment.id, itemsPerPage, searchValue]);
useEffect(() => {
if (!isFirstRender.current) {
const fetchData = async () => {
setIsDataLoaded(false);
try {
setHasMore(true);
const getPersonActionData = await getContactsAction({
environmentId: environment.id,
offset: 0,
searchValue,
});
const personData = getPersonActionData?.data;
if (getPersonActionData?.data) {
setContacts(getPersonActionData.data);
}
if (personData && personData.length < itemsPerPage) {
setHasMore(false);
}
} catch (error) {
console.error("Error fetching people data:", error);
toast.error("Error fetching people data. Please try again.");
} finally {
setIsDataLoaded(true);
}
};
const debouncedFetchData = debounce(fetchData, 300);
const debouncedFetchData = debounce(fetchContactsFromStart, 300);
debouncedFetchData();
return () => {
debouncedFetchData.cancel();
};
}
}, [environment.id, itemsPerPage, searchValue]);
}, [fetchContactsFromStart]);
useEffect(() => {
if (isFirstRender.current) {
@@ -147,6 +147,7 @@ export const ContactDataView = ({
setSearchValue={setSearchValue}
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
refreshContacts={fetchContactsFromStart}
/>
);
};
@@ -43,6 +43,7 @@ interface ContactsTableProps {
setSearchValue: (value: string) => void;
isReadOnly: boolean;
isQuotasAllowed: boolean;
refreshContacts: () => Promise<void>;
}
export const ContactsTable = ({
@@ -56,6 +57,7 @@ export const ContactsTable = ({
setSearchValue,
isReadOnly,
isQuotasAllowed,
refreshContacts,
}: ContactsTableProps) => {
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [columnOrder, setColumnOrder] = useState<string[]>([]);
@@ -235,6 +237,7 @@ export const ContactsTable = ({
type="contact"
deleteAction={deleteContact}
isQuotasAllowed={isQuotasAllowed}
onRefresh={refreshContacts}
leftContent={
<div className="w-64">
<SearchBar
@@ -91,6 +91,13 @@ export const EditContactAttributesModal = ({
return allKeyOptions.filter((option) => !selectedKeys.has(String(option.value)));
};
// Reset form when modal closes
useEffect(() => {
if (!open) {
form.reset(defaultValues);
}
}, [open, defaultValues, form]);
// Scroll to first error on validation failure
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
@@ -2,7 +2,11 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getContactAttributes, hasEmailAttribute } from "@/modules/ee/contacts/lib/contact-attributes";
import {
getContactAttributes,
hasEmailAttribute,
hasUserIdAttribute,
} from "@/modules/ee/contacts/lib/contact-attributes";
import { updateAttributes } from "./attributes";
vi.mock("@/lib/constants", () => ({
@@ -20,6 +24,7 @@ vi.mock("@/modules/ee/contacts/lib/contact-attributes", async () => {
...actual,
getContactAttributes: vi.fn(),
hasEmailAttribute: vi.fn(),
hasUserIdAttribute: vi.fn(),
};
});
vi.mock("@formbricks/database", () => ({
@@ -75,6 +80,7 @@ describe("updateAttributes", () => {
vi.clearAllMocks();
// Set default mock return values - these will be overridden in individual tests
vi.mocked(getContactAttributes).mockResolvedValue({});
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
});
@@ -83,19 +89,21 @@ describe("updateAttributes", () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
const attributes = { name: "John", email: "john@example.com" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(prisma.$transaction).toHaveBeenCalled();
expect(result.success).toBe(true);
expect(result.messages).toEqual([]);
expect(result.messages).toBeUndefined();
});
test("skips updating email if it already exists", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
vi.mocked(hasEmailAttribute).mockResolvedValue(true);
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
const attributes = { name: "John", email: "john@example.com" };
@@ -106,45 +114,147 @@ describe("updateAttributes", () => {
expect(result.messages).toContain("The email already exists for this environment and was not updated.");
});
test("creates new attributes if under limit", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[0]]);
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane" });
test("skips updating userId if it already exists", async () => {
const attributeKeysWithUserId: TContactAttributeKey[] = [
...attributeKeys,
{
id: "key-4",
key: "userId",
createdAt: new Date(),
updatedAt: new Date(),
isUnique: true,
name: "User ID",
description: null,
type: "default",
environmentId,
},
];
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeysWithUserId);
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", userId: "old-user-id" });
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(hasUserIdAttribute).mockResolvedValue(true);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
const attributes = { name: "John", newAttr: "val" };
const attributes = { name: "John", userId: "duplicate-user-id" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(prisma.$transaction).toHaveBeenCalled();
expect(result.success).toBe(true);
expect(result.messages).toEqual([]);
expect(result.messages).toContain("The userId already exists for this environment and was not updated.");
expect(result.ignoreUserIdAttribute).toBe(true);
});
test("skips updating both email and userId if both already exist", async () => {
const attributeKeysWithUserId: TContactAttributeKey[] = [
...attributeKeys,
{
id: "key-4",
key: "userId",
createdAt: new Date(),
updatedAt: new Date(),
isUnique: true,
name: "User ID",
description: null,
type: "default",
environmentId,
},
];
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeysWithUserId);
vi.mocked(getContactAttributes).mockResolvedValue({
name: "Jane",
email: "old@example.com",
userId: "old-user-id",
});
vi.mocked(hasEmailAttribute).mockResolvedValue(true);
vi.mocked(hasUserIdAttribute).mockResolvedValue(true);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
const attributes = { name: "John", email: "duplicate@example.com", userId: "duplicate-user-id" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(prisma.$transaction).toHaveBeenCalled();
expect(result.success).toBe(true);
expect(result.messages).toContain("The email already exists for this environment and was not updated.");
expect(result.messages).toContain("The userId already exists for this environment and was not updated.");
expect(result.ignoreEmailAttribute).toBe(true);
expect(result.ignoreUserIdAttribute).toBe(true);
});
test("creates new attributes if under limit", async () => {
// Use name and email keys (2 existing keys), MAX is mocked to 2
// We update existing attributes, no new ones created
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[0], attributeKeys[1]]); // name, email
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
const attributes = { name: "John", email: "john@example.com" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(prisma.$transaction).toHaveBeenCalled();
expect(result.success).toBe(true);
expect(result.messages).toBeUndefined();
});
test("does not create new attributes if over the limit", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
const attributes = { name: "John", newAttr: "val" };
// Include email to satisfy the "at least one of email or userId" requirement
const attributes = { name: "John", email: "john@example.com", newAttr: "val" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(result.success).toBe(true);
expect(result.messages?.[0]).toMatch(/Could not create 1 new attribute/);
});
test("returns success with no attributes to update or create", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue([]);
vi.mocked(getContactAttributes).mockResolvedValue({});
test("returns success with only email attribute", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[1]]); // email key
vi.mocked(getContactAttributes).mockResolvedValue({ email: "existing@example.com" });
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
const attributes = {};
const attributes = { email: "updated@example.com" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(result.success).toBe(true);
expect(result.messages).toEqual([]);
expect(result.messages).toBeUndefined();
});
test("deletes non-default attributes that are removed from payload", async () => {
test("deletes non-default attributes when deleteRemovedAttributes is true", async () => {
// Reset mocks explicitly for this test
vi.mocked(prisma.contactAttribute.deleteMany).mockClear();
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
vi.mocked(getContactAttributes).mockResolvedValue({
name: "Jane",
email: "jane@example.com",
customAttr: "oldValue",
});
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 1 });
const attributes = { name: "John", email: "john@example.com" };
// Pass deleteRemovedAttributes: true to enable deletion behavior
const result = await updateAttributes(contactId, userId, environmentId, attributes, true);
// Only customAttr (key-3) should be deleted, not name (key-1) or email (key-2)
expect(prisma.contactAttribute.deleteMany).toHaveBeenCalledWith({
where: {
contactId,
attributeKeyId: {
in: ["key-3"],
},
},
});
expect(result.success).toBe(true);
expect(result.messages).toBeUndefined();
});
test("does not delete attributes when deleteRemovedAttributes is false (default behavior)", async () => {
// Reset mocks explicitly for this test
vi.mocked(prisma.contactAttribute.deleteMany).mockClear();
@@ -156,27 +266,19 @@ describe("updateAttributes", () => {
});
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 1 });
const attributes = { name: "John", email: "john@example.com" };
// Default behavior (deleteRemovedAttributes: false) should NOT delete existing attributes
const result = await updateAttributes(contactId, userId, environmentId, attributes);
// Only customAttr (key-3) should be deleted, not name (key-1) or email (key-2)
expect(prisma.contactAttribute.deleteMany).toHaveBeenCalledWith({
where: {
contactId,
attributeKeyId: {
in: ["key-3"],
},
},
});
// deleteMany should NOT be called since we're merging, not replacing
expect(prisma.contactAttribute.deleteMany).not.toHaveBeenCalled();
expect(result.success).toBe(true);
expect(result.messages).toEqual([]);
expect(result.messages).toBeUndefined();
});
test("does not delete default attributes even if removed from payload", async () => {
test("does not delete default attributes even when deleteRemovedAttributes is true", async () => {
// Reset mocks explicitly for this test
vi.mocked(prisma.contactAttribute.deleteMany).mockClear();
// Need to include userId and firstName in attributeKeys for this test
// Note: DEFAULT_ATTRIBUTES includes: email, userId, firstName, lastName (not "name")
const attributeKeysWithDefaults: TContactAttributeKey[] = [
{
@@ -231,13 +333,105 @@ describe("updateAttributes", () => {
firstName: "John",
});
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
const attributes = { customAttr: "value" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
// Pass deleteRemovedAttributes: true to test that default attributes are still preserved
const result = await updateAttributes(contactId, userId, environmentId, attributes, true);
// Should not delete default attributes (email, userId, firstName) - deleteMany should not be called
// since all current attributes are default attributes
expect(prisma.contactAttribute.deleteMany).not.toHaveBeenCalled();
expect(result.success).toBe(true);
});
test("preserves existing email when empty string is submitted", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "existing@example.com" });
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
// Attempt to clear email by submitting empty string
const attributes = { name: "John", email: "" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
// Verify that the transaction was called with the preserved email
expect(prisma.$transaction).toHaveBeenCalled();
const transactionCall = vi.mocked(prisma.$transaction).mock.calls[0][0];
// The email should be preserved (existing@example.com), not cleared
expect(transactionCall).toHaveLength(2); // name and email
expect(result.success).toBe(true);
});
test("allows clearing userId when empty string is submitted", async () => {
const attributeKeysWithUserId: TContactAttributeKey[] = [
...attributeKeys,
{
id: "key-4",
key: "userId",
createdAt: new Date(),
updatedAt: new Date(),
isUnique: true,
name: "User ID",
description: null,
type: "default",
environmentId,
},
];
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeysWithUserId);
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", userId: "existing-user-id" });
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
// Clear userId by submitting empty string - this should be allowed
const attributes = { name: "John", userId: "" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
// Verify that the transaction was called
expect(prisma.$transaction).toHaveBeenCalled();
const transactionCall = vi.mocked(prisma.$transaction).mock.calls[0][0];
// Only name and userId (empty) should be in the transaction
expect(transactionCall).toHaveLength(2); // name and userId (with empty value)
expect(result.success).toBe(true);
});
test("preserves existing values when both email and userId would be cleared", async () => {
const attributeKeysWithBoth: TContactAttributeKey[] = [
...attributeKeys,
{
id: "key-4",
key: "userId",
createdAt: new Date(),
updatedAt: new Date(),
isUnique: true,
name: "User ID",
description: null,
type: "default",
environmentId,
},
];
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeysWithBoth);
vi.mocked(getContactAttributes).mockResolvedValue({
name: "Jane",
email: "existing@example.com",
userId: "existing-user-id",
});
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
// Attempt to clear both email and userId
const attributes = { name: "John", email: "", userId: "" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(result.success).toBe(true);
expect(result.messages).toContain(
"Either email or userId is required. The existing values were preserved."
);
});
});
+109 -22
View File
@@ -5,7 +5,11 @@ import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
import { validateInputs } from "@/lib/utils/validate";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getContactAttributes, hasEmailAttribute } from "@/modules/ee/contacts/lib/contact-attributes";
import {
getContactAttributes,
hasEmailAttribute,
hasUserIdAttribute,
} from "@/modules/ee/contacts/lib/contact-attributes";
// Default/system attributes that should not be deleted even if missing from payload
const DEFAULT_ATTRIBUTES = new Set(["email", "userId", "firstName", "lastName"]);
@@ -47,12 +51,28 @@ const deleteAttributes = async (
};
};
/**
* Updates or creates contact attributes.
*
* @param contactId - The ID of the contact to update
* @param userId - The user ID of the contact
* @param environmentId - The environment ID
* @param contactAttributesParam - The attributes to update/create
* @param deleteRemovedAttributes - When true, deletes attributes that exist in DB but are not in the payload.
* Use this for UI forms where all attributes are submitted. Default is false (merge behavior) for API calls.
*/
export const updateAttributes = async (
contactId: string,
userId: string,
environmentId: string,
contactAttributesParam: TContactAttributes
): Promise<{ success: boolean; messages?: string[]; ignoreEmailAttribute?: boolean }> => {
contactAttributesParam: TContactAttributes,
deleteRemovedAttributes: boolean = false
): Promise<{
success: boolean;
messages?: string[];
ignoreEmailAttribute?: boolean;
ignoreUserIdAttribute?: boolean;
}> => {
validateInputs(
[contactId, ZId],
[userId, ZString],
@@ -61,23 +81,89 @@ export const updateAttributes = async (
);
let ignoreEmailAttribute = false;
let ignoreUserIdAttribute = false;
const messages: string[] = [];
// Fetch current attributes, contact attribute keys, and email check in parallel
const [currentAttributes, contactAttributeKeys, existingEmailAttribute] = await Promise.all([
getContactAttributes(contactId),
getContactAttributeKeys(environmentId),
contactAttributesParam.email
? hasEmailAttribute(contactAttributesParam.email, environmentId, contactId)
: Promise.resolve(null),
]);
// Fetch current attributes, contact attribute keys, and email/userId checks in parallel
const [currentAttributes, contactAttributeKeys, existingEmailAttribute, existingUserIdAttribute] =
await Promise.all([
getContactAttributes(contactId),
getContactAttributeKeys(environmentId),
contactAttributesParam.email
? hasEmailAttribute(contactAttributesParam.email, environmentId, contactId)
: Promise.resolve(null),
contactAttributesParam.userId
? hasUserIdAttribute(contactAttributesParam.userId, environmentId, contactId)
: Promise.resolve(null),
]);
// Process email existence early
const { email, ...remainingAttributes } = contactAttributesParam;
const contactAttributes = existingEmailAttribute ? remainingAttributes : contactAttributesParam;
// Process email and userId existence early
const emailExists = !!existingEmailAttribute;
const userIdExists = !!existingUserIdAttribute;
// Delete attributes that were removed (using the deleteAttributes service)
await deleteAttributes(contactId, currentAttributes, contactAttributesParam, contactAttributeKeys);
// Remove email and/or userId from attributes if they already exist on another contact
let contactAttributes = { ...contactAttributesParam };
// Determine what the final email and userId values will be after this update
// Only consider a value as "submitted" if it was explicitly included in the attributes
const emailWasSubmitted = "email" in contactAttributesParam;
const userIdWasSubmitted = "userId" in contactAttributesParam;
const submittedEmail = emailWasSubmitted ? contactAttributes.email?.trim() || "" : null;
const submittedUserId = userIdWasSubmitted ? contactAttributes.userId?.trim() || "" : null;
const currentEmail = currentAttributes.email || "";
const currentUserId = currentAttributes.userId || "";
// Calculate final values:
// - If not submitted, keep current value
// - If submitted but duplicate exists, keep current value
// - If submitted and no duplicate, use submitted value
const getFinalEmail = (): string => {
if (submittedEmail === null) return currentEmail;
if (emailExists) return currentEmail;
return submittedEmail;
};
const getFinalUserId = (): string => {
if (submittedUserId === null) return currentUserId;
if (userIdExists) return currentUserId;
return submittedUserId;
};
const finalEmail = getFinalEmail();
const finalUserId = getFinalUserId();
// Ensure at least one of email or userId will have a value after update
if (!finalEmail && !finalUserId) {
// If both would be empty, preserve the current values
if (currentEmail) {
contactAttributes.email = currentEmail;
}
if (currentUserId) {
contactAttributes.userId = currentUserId;
}
messages.push("Either email or userId is required. The existing values were preserved.");
}
if (emailExists) {
const { email: _email, ...rest } = contactAttributes;
contactAttributes = rest;
ignoreEmailAttribute = true;
}
if (userIdExists) {
const { userId: _userId, ...rest } = contactAttributes;
contactAttributes = rest;
ignoreUserIdAttribute = true;
}
// Delete attributes that were removed (only when explicitly requested)
// This is used by UI forms where all attributes are submitted
// For API calls, we want merge behavior by default (only update passed attributes)
if (deleteRemovedAttributes) {
await deleteAttributes(contactId, currentAttributes, contactAttributesParam, contactAttributeKeys);
}
// Create lookup map for attribute keys
const contactAttributeKeyMap = new Map(contactAttributeKeys.map((ack) => [ack.key, ack]));
@@ -99,12 +185,12 @@ export const updateAttributes = async (
}
);
let messages: string[] = emailExists
? ["The email already exists for this environment and was not updated."]
: [];
if (emailExists) {
ignoreEmailAttribute = true;
messages.push("The email already exists for this environment and was not updated.");
}
if (userIdExists) {
messages.push("The userId already exists for this environment and was not updated.");
}
// Update all existing attributes
@@ -159,7 +245,8 @@ export const updateAttributes = async (
return {
success: true,
messages,
messages: messages.length > 0 ? messages : undefined,
ignoreEmailAttribute,
ignoreUserIdAttribute,
};
};
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getContactAttributes, hasEmailAttribute } from "./contact-attributes";
import { TContactAttribute } from "@formbricks/types/contact-attribute";
import { getContactAttributes, hasEmailAttribute, hasUserIdAttribute } from "./contact-attributes";
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -16,11 +17,12 @@ vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
const contactId = "contact-1";
const environmentId = "env-1";
const email = "john@example.com";
const userId = "user-123";
const mockAttributes = [
{ value: "john@example.com", attributeKey: { key: "email", name: "Email" } },
{ value: "John", attributeKey: { key: "name", name: "Name" } },
];
] as unknown as TContactAttribute[];
describe("getContactAttributes", () => {
beforeEach(() => {
@@ -50,7 +52,9 @@ describe("hasEmailAttribute", () => {
});
test("returns true if email attribute exists", async () => {
vi.mocked(prisma.contactAttribute.findFirst).mockResolvedValue({ id: "attr-1" });
vi.mocked(prisma.contactAttribute.findFirst).mockResolvedValue({
id: "attr-1",
} as unknown as TContactAttribute);
const result = await hasEmailAttribute(email, environmentId, contactId);
expect(prisma.contactAttribute.findFirst).toHaveBeenCalledWith({
where: {
@@ -67,3 +71,29 @@ describe("hasEmailAttribute", () => {
expect(result).toBe(false);
});
});
describe("hasUserIdAttribute", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns true if userId attribute exists on another contact", async () => {
vi.mocked(prisma.contactAttribute.findFirst).mockResolvedValue({
id: "attr-1",
} as unknown as TContactAttribute);
const result = await hasUserIdAttribute(userId, environmentId, contactId);
expect(prisma.contactAttribute.findFirst).toHaveBeenCalledWith({
where: {
AND: [{ attributeKey: { key: "userId", environmentId }, value: userId }, { NOT: { contactId } }],
},
select: { id: true },
});
expect(result).toBe(true);
});
test("returns false if userId attribute does not exist on another contact", async () => {
vi.mocked(prisma.contactAttribute.findFirst).mockResolvedValue(null);
const result = await hasUserIdAttribute(userId, environmentId, contactId);
expect(result).toBe(false);
});
});
@@ -1,7 +1,7 @@
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { ZId, ZString } from "@formbricks/types/common";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError } from "@formbricks/types/errors";
import { ZUserEmail } from "@formbricks/types/user";
@@ -68,3 +68,31 @@ export const hasEmailAttribute = reactCache(
return !!contactAttribute;
}
);
export const hasUserIdAttribute = reactCache(
async (userId: string, environmentId: string, contactId: string): Promise<boolean> => {
validateInputs([userId, ZString], [environmentId, ZId], [contactId, ZId]);
const contactAttribute = await prisma.contactAttribute.findFirst({
where: {
AND: [
{
attributeKey: {
key: "userId",
environmentId,
},
value: userId,
},
{
NOT: {
contactId,
},
},
],
},
select: { id: true },
});
return !!contactAttribute;
}
);
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { updateAttributes } from "./attributes";
import { getContactAttributeKeys } from "./contact-attribute-keys";
import { getContactAttributes } from "./contact-attributes";
@@ -16,7 +16,7 @@ describe("updateContactAttributes", () => {
vi.clearAllMocks();
});
it("should update contact attributes successfully", async () => {
test("should update contact attributes with deleteRemovedAttributes: true", async () => {
const contactId = "contact123";
const environmentId = "env123";
const userId = "user123";
@@ -91,13 +91,14 @@ describe("updateContactAttributes", () => {
expect(getContact).toHaveBeenCalledWith(contactId);
expect(getContactAttributeKeys).toHaveBeenCalledWith(environmentId);
expect(updateAttributes).toHaveBeenCalledWith(contactId, userId, environmentId, attributes);
// Should call updateAttributes with deleteRemovedAttributes: true for UI form updates
expect(updateAttributes).toHaveBeenCalledWith(contactId, userId, environmentId, attributes, true);
expect(getContactAttributes).toHaveBeenCalledWith(contactId);
expect(result.updatedAttributes).toEqual(mockUpdatedAttributes);
expect(result.updatedAttributeKeys).toBeUndefined();
});
it("should detect new attribute keys when created", async () => {
test("should detect new attribute keys when created", async () => {
const contactId = "contact123";
const environmentId = "env123";
const userId = "user123";
@@ -184,7 +185,7 @@ describe("updateContactAttributes", () => {
]);
});
it("should handle missing userId with warning message", async () => {
test("should handle missing userId gracefully", async () => {
const contactId = "contact123";
const environmentId = "env123";
const attributes = {
@@ -226,13 +227,13 @@ describe("updateContactAttributes", () => {
const result = await updateContactAttributes(contactId, attributes);
expect(updateAttributes).toHaveBeenCalledWith(contactId, "", environmentId, attributes);
expect(result.messages).toContain(
"Warning: userId attribute is missing. Some operations may not work correctly."
);
// When userId is not in attributes, pass empty string to updateAttributes
expect(updateAttributes).toHaveBeenCalledWith(contactId, "", environmentId, attributes, true);
// No warning message - the backend now gracefully handles missing userId by keeping current value
expect(result.messages).toBeUndefined();
});
it("should merge messages from updateAttributes", async () => {
test("should merge messages from updateAttributes", async () => {
const contactId = "contact123";
const environmentId = "env123";
const userId = "user123";
@@ -279,7 +280,7 @@ describe("updateContactAttributes", () => {
expect(result.messages).toContain("The email already exists for this environment and was not updated.");
});
it("should throw error if contact not found", async () => {
test("should throw error if contact not found", async () => {
const contactId = "contact123";
const attributes = {
firstName: "John",
@@ -13,11 +13,6 @@ export interface UpdateContactAttributesResult {
updatedAttributeKeys?: TContactAttributeKey[];
}
/**
* Updates contact attributes for a single contact.
* Handles loading contact data, extracting userId, calling updateAttributes,
* and detecting if new attribute keys were created.
*/
export const updateContactAttributes = async (
contactId: string,
attributes: TContactAttributes
@@ -35,16 +30,13 @@ export const updateContactAttributes = async (
const userId = attributes.userId ?? "";
const messages: string[] = [];
if (!attributes.userId) {
messages.push("Warning: userId attribute is missing. Some operations may not work correctly.");
}
// Get current attribute keys before update to detect new ones
const currentAttributeKeys = await getContactAttributeKeys(environmentId);
const currentKeysSet = new Set(currentAttributeKeys.map((key) => key.key));
// Call the existing updateAttributes function
const updateResult = await updateAttributes(contactId, userId, environmentId, attributes);
// Call updateAttributes with deleteRemovedAttributes: true
// UI forms submit all attributes, so any missing attribute should be deleted
const updateResult = await updateAttributes(contactId, userId, environmentId, attributes, true);
// Merge any messages from updateAttributes
if (updateResult.messages) {
+1 -1
View File
@@ -3,7 +3,7 @@ import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { TContactWithAttributes, TTransformPersonInput } from "@/modules/ee/contacts/types/contact";
export const getContactIdentifier = (contactAttributes: TContactAttributes | null): string => {
return contactAttributes?.email ?? contactAttributes?.userId ?? "";
return contactAttributes?.email || contactAttributes?.userId || "";
};
export const convertPrismaContactAttributes = (
+28 -1
View File
@@ -335,7 +335,34 @@ export const ZEditContactAttributesForm = z.object({
}
});
// Validate email format if key is "email"
// Check that at least one of email or userId has a value
const emailAttr = attributes.find((attr) => attr.key === "email");
const userIdAttr = attributes.find((attr) => attr.key === "userId");
const hasEmail = emailAttr?.value && emailAttr.value.trim() !== "";
const hasUserId = userIdAttr?.value && userIdAttr.value.trim() !== "";
if (!hasEmail && !hasUserId) {
// Find the indices to show errors on the relevant fields
const emailIndex = attributes.findIndex((attr) => attr.key === "email");
const userIdIndex = attributes.findIndex((attr) => attr.key === "userId");
// When both are empty, show "Either email or userId is required" on both fields
if (emailIndex !== -1 && userIdIndex !== -1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Either email or userId is required",
path: [emailIndex, "value"],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Either email or userId is required",
path: [userIdIndex, "value"],
});
}
}
// Validate email format if key is "email" and has a value
attributes.forEach((attr, index) => {
if (attr.key === "email" && attr.value && attr.value.trim() !== "") {
const emailResult = z.string().email().safeParse(attr.value);
@@ -11,6 +11,7 @@ import {
vi.mock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
@@ -690,4 +691,61 @@ describe("License Core Logic", () => {
);
});
});
describe("Environment-based endpoint selection", () => {
test("should use staging endpoint when ENVIRONMENT is staging", async () => {
vi.resetModules();
vi.doMock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "staging",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
},
}));
const fetch = (await import("node-fetch")).default as Mock;
// Mock cache.withCache to execute the function (simulating cache miss)
mockCache.withCache.mockImplementation(async (fn) => await fn());
fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
status: "active",
features: {
isMultiOrgEnabled: true,
projects: 5,
twoFactorAuth: true,
sso: true,
whitelabel: true,
removeBranding: true,
contacts: true,
ai: true,
saml: true,
spamProtection: true,
auditLogs: true,
multiLanguageSurveys: true,
accessControl: true,
quotas: true,
},
},
}),
} as any);
// Re-import the module to apply the new mock
const { fetchLicense } = await import("./license");
await fetchLicense();
// Verify the staging endpoint was called
expect(fetch).toHaveBeenCalledWith(
"https://staging.ee.formbricks.com/api/licenses/check",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
})
);
});
});
});
@@ -26,7 +26,10 @@ const CONFIG = {
RETRY_DELAY_MS: 1000,
},
API: {
ENDPOINT: "https://ee.formbricks.com/api/licenses/check",
ENDPOINT:
env.ENVIRONMENT === "staging"
? "https://staging.ee.formbricks.com/api/licenses/check"
: "https://ee.formbricks.com/api/licenses/check",
TIMEOUT_MS: 5000,
},
} as const;
@@ -153,6 +153,7 @@ export const getEnvironmentWithRelations = reactCache(async (environmentId: stri
darkOverlay: true,
styling: true,
logo: true,
customHeadScripts: true,
// All project environments
environments: {
select: {
@@ -222,6 +223,7 @@ export const getEnvironmentWithRelations = reactCache(async (environmentId: stri
darkOverlay: data.project.darkOverlay,
styling: data.project.styling,
logo: data.project.logo,
customHeadScripts: data.project.customHeadScripts,
environments: data.project.environments,
},
organization: {
@@ -0,0 +1,124 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangleIcon } from "lucide-react";
import { SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { TProject } from "@formbricks/types/project";
import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { updateProjectAction } from "../../actions";
interface CustomScriptsFormProps {
project: TProject;
isReadOnly: boolean;
}
const ZCustomScriptsInput = z.object({
customHeadScripts: z.string().nullish(),
});
type TCustomScriptsFormValues = z.infer<typeof ZCustomScriptsInput>;
export const CustomScriptsForm: React.FC<CustomScriptsFormProps> = ({ project, isReadOnly }) => {
const { t } = useTranslation();
const form = useForm<TCustomScriptsFormValues>({
defaultValues: {
customHeadScripts: project.customHeadScripts ?? "",
},
resolver: zodResolver(ZCustomScriptsInput),
mode: "onChange",
});
const { isDirty, isSubmitting } = form.formState;
const updateCustomScripts: SubmitHandler<TCustomScriptsFormValues> = async (data) => {
try {
const updatedProjectResponse = await updateProjectAction({
projectId: project.id,
data: {
customHeadScripts: data.customHeadScripts || null,
},
});
if (updatedProjectResponse?.data) {
toast.success(t("environments.workspace.general.custom_scripts_updated_successfully"));
form.reset({ customHeadScripts: updatedProjectResponse.data.customHeadScripts ?? "" });
} else {
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
toast.error(errorMessage);
}
} catch {
toast.error(t("common.something_went_wrong_please_try_again"));
}
};
return (
<>
<FormProvider {...form}>
<form className="flex w-full flex-col space-y-4" onSubmit={form.handleSubmit(updateCustomScripts)}>
<Alert variant="warning" className="flex items-start gap-2">
<AlertTriangleIcon className="mt-0.5 h-4 w-4 shrink-0" />
<AlertDescription>{t("environments.workspace.general.custom_scripts_warning")}</AlertDescription>
</Alert>
<FormField
control={form.control}
name="customHeadScripts"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="customHeadScripts">
{t("environments.workspace.general.custom_scripts_label")}
</FormLabel>
<FormDescription>
{t("environments.workspace.general.custom_scripts_description")}
</FormDescription>
<FormControl>
<textarea
id="customHeadScripts"
rows={8}
placeholder={t("environments.workspace.general.custom_scripts_placeholder")}
className={cn(
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50",
isReadOnly && "bg-slate-50"
)}
{...field}
value={field.value ?? ""}
disabled={isReadOnly}
/>
</FormControl>
</FormItem>
)}
/>
<Button
type="submit"
size="sm"
className="w-fit"
loading={isSubmitting}
disabled={isSubmitting || !isDirty || isReadOnly}>
{t("common.save")}
</Button>
</form>
</FormProvider>
{isReadOnly && (
<Alert variant="warning" className="mt-4">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</>
);
};
@@ -8,6 +8,7 @@ import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import packageJson from "@/package.json";
import { CustomScriptsForm } from "./components/custom-scripts-form";
import { DeleteProject } from "./components/delete-project";
import { EditProjectNameForm } from "./components/edit-project-name-form";
import { EditWaitingTimeForm } from "./components/edit-waiting-time-form";
@@ -39,6 +40,13 @@ export const GeneralSettingsPage = async (props: { params: Promise<{ environment
description={t("environments.workspace.general.recontact_waiting_time_settings_description")}>
<EditWaitingTimeForm project={project} isReadOnly={isReadOnly} />
</SettingsCard>
{!IS_FORMBRICKS_CLOUD && (
<SettingsCard
title={t("environments.workspace.general.custom_scripts")}
description={t("environments.workspace.general.custom_scripts_card_description")}>
<CustomScriptsForm project={project} isReadOnly={!isOwnerOrManager} />
</SettingsCard>
)}
<SettingsCard
title={t("environments.workspace.general.delete_workspace")}
description={t("environments.workspace.general.delete_workspace_settings_description")}>
@@ -28,6 +28,7 @@ const selectProject = {
environments: true,
styling: true,
logo: true,
customHeadScripts: true,
};
export const updateProject = async (
@@ -112,7 +112,7 @@ export const InviteMembers = ({ IS_SMTP_CONFIGURED, organizationId }: InviteMemb
<div className="relative">
<Input
{...field}
placeholder={`Full Name (optional)`}
placeholder={t("common.full_name")}
className="w-80"
isInvalid={Boolean(error?.message)}
/>
@@ -9,7 +9,6 @@ import { type TI18nString } from "@formbricks/types/i18n";
import {
TSurveyElement,
TSurveyElementChoice,
TSurveyElementTypeEnum,
} from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyEndScreenCard, TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
@@ -24,7 +23,6 @@ import { Button } from "@/modules/ui/components/button";
import { FileInput } from "@/modules/ui/components/file-input";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import {
determineImageUploaderVisibility,
@@ -315,70 +313,6 @@ export const ElementFormInput = ({
return false;
};
const getIsRequiredToggleDisabled = (): boolean => {
if (!currentElement) return false;
// CTA elements should always have the required toggle disabled
if (currentElement.type === TSurveyElementTypeEnum.CTA) {
return true;
}
if (currentElement.type === TSurveyElementTypeEnum.Address) {
const allFieldsAreOptional = [
currentElement.addressLine1,
currentElement.addressLine2,
currentElement.city,
currentElement.state,
currentElement.zip,
currentElement.country,
]
.filter((field) => field.show)
.every((field) => !field.required);
if (allFieldsAreOptional) {
return true;
}
return [
currentElement.addressLine1,
currentElement.addressLine2,
currentElement.city,
currentElement.state,
currentElement.zip,
currentElement.country,
]
.filter((field) => field.show)
.some((condition) => condition.required === true);
}
if (currentElement.type === TSurveyElementTypeEnum.ContactInfo) {
const allFieldsAreOptional = [
currentElement.firstName,
currentElement.lastName,
currentElement.email,
currentElement.phone,
currentElement.company,
]
.filter((field) => field.show)
.every((field) => !field.required);
if (allFieldsAreOptional) {
return true;
}
return [
currentElement.firstName,
currentElement.lastName,
currentElement.email,
currentElement.phone,
currentElement.company,
]
.filter((field) => field.show)
.some((condition) => condition.required === true);
}
return false;
};
const useRichTextEditor = id === "headline" || id === "subheader" || id === "html";
@@ -393,21 +327,6 @@ export const ElementFormInput = ({
{label && (
<div className="mb-2 mt-3 flex items-center justify-between">
<Label htmlFor={id}>{label}</Label>
{id === "headline" && currentElement && updateElement && (
<div className="flex items-center space-x-2">
<Label htmlFor="required-toggle" className="text-sm">
{t("environments.surveys.edit.required")}
</Label>
<Switch
id="required-toggle"
checked={currentElement.required}
disabled={getIsRequiredToggleDisabled()}
onCheckedChange={(checked) => {
updateElement(elementIdx, { required: checked });
}}
/>
</div>
)}
</div>
)}
<div className="flex flex-col gap-4" ref={animationParent}>
@@ -523,21 +442,6 @@ export const ElementFormInput = ({
{label && (
<div className="mb-2 mt-3 flex items-center justify-between">
<Label htmlFor={id}>{label}</Label>
{id === "headline" && currentElement && updateElement && (
<div className="flex items-center space-x-2">
<Label htmlFor="required-toggle" className="text-sm">
{t("environments.surveys.edit.required")}
</Label>
<Switch
id="required-toggle"
checked={currentElement.required}
disabled={getIsRequiredToggleDisabled()}
onCheckedChange={(checked) => {
updateElement(elementIdx, { required: checked });
}}
/>
</div>
)}
</div>
)}
<MultiLangWrapper
@@ -4,11 +4,13 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { PlusIcon } from "lucide-react";
import { type JSX, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyAddressElement } from "@formbricks/types/surveys/elements";
import { TSurveyAddressElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForAddress } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { Button } from "@/modules/ui/components/button";
import { ElementToggleTable } from "@/modules/ui/components/element-toggle-table";
@@ -159,6 +161,16 @@ export const AddressElementForm = ({
isStorageConfigured={isStorageConfigured}
/>
</div>
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.Address}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForAddress) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</form>
);
};
@@ -3,11 +3,13 @@
import { PlusIcon } from "lucide-react";
import { type JSX, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyCalElement } from "@formbricks/types/surveys/elements";
import { TSurveyCalElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForCal } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
@@ -143,6 +145,16 @@ export const CalElementForm = ({
</AdvancedOptionToggle>
</div>
</div>
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.Cal}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForCal) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</form>
);
};
@@ -4,11 +4,13 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { PlusIcon } from "lucide-react";
import { type JSX } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyConsentElement } from "@formbricks/types/surveys/elements";
import { TSurveyConsentElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForConsent } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { Button } from "@/modules/ui/components/button";
interface ConsentElementFormProps {
@@ -102,6 +104,16 @@ export const ConsentElementForm = ({
placeholder="I agree to the terms and conditions"
value={element.label}
/>
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.Consent}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForConsent) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</form>
);
};
@@ -4,11 +4,13 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { PlusIcon } from "lucide-react";
import { type JSX, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyContactInfoElement } from "@formbricks/types/surveys/elements";
import { TSurveyContactInfoElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForContactInfo } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { Button } from "@/modules/ui/components/button";
import { ElementToggleTable } from "@/modules/ui/components/element-toggle-table";
@@ -156,6 +158,16 @@ export const ContactInfoElementForm = ({
isStorageConfigured={isStorageConfigured}
/>
</div>
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.ContactInfo}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForContactInfo) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</form>
);
};
@@ -111,7 +111,7 @@ export const CTAElementForm = ({
description={t("environments.surveys.edit.button_external_description")}
childBorder
customContainerClass="p-0 mt-4">
<div className="flex flex-1 flex-col gap-2 px-4 pb-4 pt-1">
<div className="flex flex-1 flex-col gap-2 px-4 pt-1 pb-4">
<ElementFormInput
id="ctaButtonLabel"
value={element.ctaButtonLabel}
@@ -133,6 +133,7 @@ export const CTAElementForm = ({
<Input
id="buttonUrl"
name="buttonUrl"
className="mt-1 bg-white"
value={element.buttonUrl}
placeholder="https://website.com"
onChange={(e) => updateElement(elementIdx, { buttonUrl: e.target.value })}
@@ -4,11 +4,13 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { PlusIcon } from "lucide-react";
import { type JSX } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyDateElement } from "@formbricks/types/surveys/elements";
import { TSurveyDateElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForDate } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
@@ -126,6 +128,16 @@ export const DateElementForm = ({
/>
</div>
</div>
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.Date}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForDate) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</form>
);
};
@@ -8,11 +8,13 @@ import { type JSX, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/storage";
import { TSurveyFileUploadElement } from "@formbricks/types/surveys/elements";
import { TSurveyElementTypeEnum, TSurveyFileUploadElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForFileUpload } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
@@ -229,7 +231,7 @@ export const FileUploadElementForm = ({
updateElement(elementIdx, { maxSizeInMB: parseInt(e.target.value, 10) });
}}
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm"
className="mr-2 ml-2 inline w-20 bg-white text-center text-sm"
/>
MB
</p>
@@ -290,6 +292,16 @@ export const FileUploadElementForm = ({
</div>
</AdvancedOptionToggle>
</div>
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.FileUpload}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForFileUpload) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</form>
);
};
@@ -9,12 +9,14 @@ import { type JSX, useCallback } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyMatrixElement } from "@formbricks/types/surveys/elements";
import { TSurveyElementTypeEnum, TSurveyMatrixElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForMatrix } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { MatrixSortableItem } from "@/modules/survey/editor/components/matrix-sortable-item";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
@@ -347,6 +349,16 @@ export const MatrixElementForm = ({
</div>
</div>
</div>
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.Matrix}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForMatrix) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</form>
);
};
@@ -12,11 +12,16 @@ import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
import { TShuffleOption, TSurvey } from "@formbricks/types/surveys/types";
import {
TValidationRulesForMultipleChoiceMulti,
TValidationRulesForMultipleChoiceSingle,
} from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { BulkEditOptionsModal } from "@/modules/survey/editor/components/bulk-edit-options-modal";
import { ElementOptionChoice } from "@/modules/survey/editor/components/element-option-choice";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
@@ -398,6 +403,28 @@ export const MultipleChoiceElementForm = ({
surveyLanguageCodes={surveyLanguageCodes}
locale={locale}
/>
{element.type === TSurveyElementTypeEnum.MultipleChoiceMulti ? (
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.MultipleChoiceMulti}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForMultipleChoiceMulti) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
) : (
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.MultipleChoiceSingle}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForMultipleChoiceSingle) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
)}
</form>
);
};
@@ -4,11 +4,13 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { PlusIcon } from "lucide-react";
import { type JSX } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyNPSElement } from "@formbricks/types/surveys/elements";
import { TSurveyElementTypeEnum, TSurveyNPSElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForNPS } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
@@ -140,6 +142,16 @@ export const NPSElementForm = ({
childBorder
customContainerClass="p-0 mt-4"
/>
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.NPS}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForNPS) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</form>
);
};
@@ -1,19 +1,22 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { HashIcon, LinkIcon, MailIcon, MessageSquareTextIcon, PhoneIcon, PlusIcon } from "lucide-react";
import { JSX, useEffect, useState } from "react";
import { PlusIcon } from "lucide-react";
import { JSX } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyOpenTextElement, TSurveyOpenTextElementInputType } from "@formbricks/types/surveys/elements";
import {
TSurveyElementTypeEnum,
TSurveyOpenTextElement,
TSurveyOpenTextElementInputType,
} from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForOpenText } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
interface OpenElementFormProps {
localSurvey: TSurvey;
@@ -42,43 +45,10 @@ export const OpenElementForm = ({
isExternalUrlsAllowed,
}: OpenElementFormProps): JSX.Element => {
const { t } = useTranslation();
const elementTypes = [
{ value: "text", label: t("common.text"), icon: <MessageSquareTextIcon className="h-4 w-4" /> },
{ value: "email", label: t("common.email"), icon: <MailIcon className="h-4 w-4" /> },
{ value: "url", label: t("common.url"), icon: <LinkIcon className="h-4 w-4" /> },
{ value: "number", label: t("common.number"), icon: <HashIcon className="h-4 w-4" /> },
{ value: "phone", label: t("common.phone"), icon: <PhoneIcon className="h-4 w-4" /> },
];
const defaultPlaceholder = getPlaceholderByInputType(element.inputType ?? "text");
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages ?? []);
const [showCharLimits, setShowCharLimits] = useState(element.inputType === "text");
const handleInputChange = (inputType: TSurveyOpenTextElementInputType) => {
const updatedAttributes = {
inputType: inputType,
placeholder: createI18nString(getPlaceholderByInputType(inputType), surveyLanguageCodes),
longAnswer: inputType === "text" ? element.longAnswer : false,
charLimit: {
min: undefined,
max: undefined,
},
};
setIsCharLimitEnabled(false);
setShowCharLimits(inputType === "text");
updateElement(elementIdx, updatedAttributes);
};
const [parent] = useAutoAnimate();
const [isCharLimitEnabled, setIsCharLimitEnabled] = useState(false);
useEffect(() => {
if (element?.charLimit?.min !== undefined || element?.charLimit?.max !== undefined) {
setIsCharLimitEnabled(true);
} else {
setIsCharLimitEnabled(false);
}
}, [element?.charLimit?.max, element?.charLimit?.min]);
return (
<form>
@@ -156,80 +126,7 @@ export const OpenElementForm = ({
/>
</div>
{/* Add a dropdown to select the element type */}
<div className="mt-3">
<Label htmlFor="elementType">{t("common.input_type")}</Label>
<div className="mt-2 flex items-center">
<OptionsSwitch
options={elementTypes}
currentOption={element.inputType}
handleOptionChange={handleInputChange} // Use the merged function
/>
</div>
</div>
<div className="mt-6 space-y-6">
{showCharLimits && (
<AdvancedOptionToggle
isChecked={isCharLimitEnabled}
onToggle={(checked: boolean) => {
setIsCharLimitEnabled(checked);
updateElement(elementIdx, {
charLimit: {
enabled: checked,
min: undefined,
max: undefined,
},
});
}}
htmlId={`charLimit-${element.id}`}
description={t("environments.surveys.edit.character_limit_toggle_description")}
childBorder
title={t("environments.surveys.edit.character_limit_toggle_title")}
customContainerClass="p-0">
<div className="flex gap-4 p-4">
<div className="flex items-center gap-2">
<Label htmlFor="minLength">{t("common.minimum")}</Label>
<Input
id="minLength"
name="minLength"
type="number"
min={0}
value={element?.charLimit?.min || ""}
aria-label={t("common.minimum")}
className="bg-white"
onChange={(e) =>
updateElement(elementIdx, {
charLimit: {
...element?.charLimit,
min: e.target.value ? parseInt(e.target.value) : undefined,
},
})
}
/>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="maxLength">{t("common.maximum")}</Label>
<Input
id="maxLength"
name="maxLength"
type="number"
min={0}
aria-label={t("common.maximum")}
value={element?.charLimit?.max || ""}
className="bg-white"
onChange={(e) =>
updateElement(elementIdx, {
charLimit: {
...element?.charLimit,
max: e.target.value ? parseInt(e.target.value) : undefined,
},
})
}
/>
</div>
</div>
</AdvancedOptionToggle>
)}
<div className="mt-4">
<AdvancedOptionToggle
isChecked={element.longAnswer !== false}
@@ -245,6 +142,16 @@ export const OpenElementForm = ({
customContainerClass="p-0"
/>
</div>
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.OpenText}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForOpenText) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</div>
</form>
);
@@ -5,12 +5,14 @@ import { createId } from "@paralleldrive/cuid2";
import { PlusIcon } from "lucide-react";
import { type JSX } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyPictureSelectionElement } from "@formbricks/types/surveys/elements";
import { TSurveyElementTypeEnum, TSurveyPictureSelectionElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForPictureSelection } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { Button } from "@/modules/ui/components/button";
import { FileInput } from "@/modules/ui/components/file-input";
import { Label } from "@/modules/ui/components/label";
@@ -170,6 +172,16 @@ export const PictureSelectionForm = ({
</div>
</Label>
</div>
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.PictureSelection}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForPictureSelection) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</form>
);
};
@@ -8,12 +8,14 @@ import { PlusIcon } from "lucide-react";
import { type JSX, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyRankingElement } from "@formbricks/types/surveys/elements";
import { TSurveyElementTypeEnum, TSurveyRankingElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForRanking } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ElementOptionChoice } from "@/modules/survey/editor/components/element-option-choice";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
@@ -246,6 +248,16 @@ export const RankingElementForm = ({
</div>
</div>
</div>
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.Ranking}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForRanking) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</form>
);
};
@@ -3,12 +3,14 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { HashIcon, PlusIcon, SmileIcon, StarIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TSurveyRatingElement } from "@formbricks/types/surveys/elements";
import { TSurveyElementTypeEnum, TSurveyRatingElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForRating } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { Dropdown } from "@/modules/survey/editor/components/rating-type-dropdown";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
@@ -189,6 +191,16 @@ export const RatingElementForm = ({
customContainerClass="p-0 mt-4"
/>
)}
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.Rating}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForRating) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</form>
);
};
@@ -0,0 +1,30 @@
"use client";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVerticalIcon } from "lucide-react";
interface ValidationRuleItemProps {
id: string;
children: React.ReactNode;
}
export const ValidationRuleItem = ({ id, children }: ValidationRuleItemProps) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : 1,
position: isDragging ? "relative" : "static",
} as React.CSSProperties;
return (
<div ref={setNodeRef} style={style} className="flex w-full items-center gap-2">
<div {...attributes} {...listeners} className="cursor-move text-slate-400 hover:text-slate-600">
<GripVerticalIcon className="h-4 w-4" />
</div>
{children}
</div>
);
};
@@ -0,0 +1,261 @@
"use client";
import { DndContext, DragEndEvent, PointerSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { PlusIcon, TrashIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { v4 as uuidv7 } from "uuid";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TValidationRule, TValidationRuleType } from "@formbricks/types/surveys/validation-rules";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { cn } from "@/modules/ui/lib/utils";
import { RULE_TYPE_CONFIG } from "../lib/validation-rules-config";
import { createRuleParams, getAvailableRuleTypes, getRuleValue } from "../lib/validation-rules-utils";
import { ValidationRuleItem } from "./validation-rule-item";
interface ValidationRulesEditorProps {
elementType: TSurveyElementTypeEnum;
validationRules: TValidationRule[];
onUpdateRules: (rules: TValidationRule[]) => void;
}
export const ValidationRulesEditor = ({
elementType,
validationRules,
onUpdateRules,
}: ValidationRulesEditorProps) => {
const { t } = useTranslation();
const ruleLabels: Record<string, string> = {
required: t("environments.surveys.edit.validation.required"),
min_length: t("environments.surveys.edit.validation.min_length"),
max_length: t("environments.surveys.edit.validation.max_length"),
pattern: t("environments.surveys.edit.validation.pattern"),
email: t("environments.surveys.edit.validation.email"),
url: t("environments.surveys.edit.validation.url"),
phone: t("environments.surveys.edit.validation.phone"),
min_value: t("environments.surveys.edit.validation.min_value"),
max_value: t("environments.surveys.edit.validation.max_value"),
min_selections: t("environments.surveys.edit.validation.min_selections"),
max_selections: t("environments.surveys.edit.validation.max_selections"),
characters: t("environments.surveys.edit.validation.characters"),
options_selected: t("environments.surveys.edit.validation.options_selected"),
};
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5,
},
})
);
const isEnabled = validationRules.length > 0;
const handleEnable = () => {
const availableRules = getAvailableRuleTypes(elementType, []);
if (availableRules.length > 0) {
const defaultRuleType = availableRules[0];
const config = RULE_TYPE_CONFIG[defaultRuleType];
let defaultValue: number | string | undefined = undefined;
if (config.needsValue && config.valueType === "text") {
defaultValue = "";
}
const newRule: TValidationRule = {
id: uuidv7(),
type: defaultRuleType,
params: createRuleParams(defaultRuleType, defaultValue),
} as TValidationRule;
onUpdateRules([newRule]);
}
};
const handleDisable = () => {
onUpdateRules([]);
};
const handleAddRule = (insertAfterIndex: number) => {
const availableRules = getAvailableRuleTypes(elementType, validationRules);
if (availableRules.length === 0) return;
const newRuleType = availableRules[0];
const config = RULE_TYPE_CONFIG[newRuleType];
let defaultValue: number | string | undefined = undefined;
if (config.needsValue && config.valueType === "text") {
defaultValue = "";
}
const newRule: TValidationRule = {
id: uuidv7(),
type: newRuleType,
params: createRuleParams(newRuleType, defaultValue),
} as TValidationRule;
const newRules = [...validationRules];
newRules.splice(insertAfterIndex + 1, 0, newRule);
onUpdateRules(newRules);
};
const handleDeleteRule = (ruleId: string) => {
const updated = validationRules.filter((r) => r.id !== ruleId);
onUpdateRules(updated);
};
const handleRuleTypeChange = (ruleId: string, newType: TValidationRuleType) => {
const updated = validationRules.map((rule) => {
if (rule.id !== ruleId) return rule;
return {
...rule,
type: newType,
params: createRuleParams(newType),
} as TValidationRule;
});
onUpdateRules(updated);
};
const handleRuleValueChange = (ruleId: string, value: string) => {
const updated = validationRules.map((rule) => {
if (rule.id !== ruleId) return rule;
const ruleType = rule.type;
const config = RULE_TYPE_CONFIG[ruleType];
const parsedValue = config.valueType === "number" ? Number(value) || 0 : value;
return {
...rule,
params: createRuleParams(ruleType, parsedValue),
} as TValidationRule;
});
onUpdateRules(updated);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const oldIndex = validationRules.findIndex((rule) => rule.id === active.id);
const newIndex = validationRules.findIndex((rule) => rule.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
const newRules = [...validationRules];
const [movedRule] = newRules.splice(oldIndex, 1);
newRules.splice(newIndex, 0, movedRule);
onUpdateRules(newRules);
}
};
const availableRulesForAdd = getAvailableRuleTypes(elementType, validationRules);
const canAddMore = availableRulesForAdd.length > 0;
return (
<AdvancedOptionToggle
isChecked={isEnabled}
onToggle={(checked) => (checked ? handleEnable() : handleDisable())}
htmlId="validation-rules-toggle"
title={t("environments.surveys.edit.validation_rules")}
description={t("environments.surveys.edit.validation_rules_description")}
customContainerClass="p-0 mt-4"
childrenContainerClass="flex-col p-3 gap-2">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={validationRules.map((r) => r.id)} strategy={verticalListSortingStrategy}>
<div className="flex w-full flex-col gap-2">
{validationRules.map((rule, index) => {
const ruleType = rule.type;
const config = RULE_TYPE_CONFIG[ruleType];
const currentValue = getRuleValue(rule);
// Get available types for this rule (current type + unused types, no duplicates)
const otherAvailableTypes = getAvailableRuleTypes(
elementType,
validationRules.filter((r) => r.id !== rule.id)
).filter((t) => t !== ruleType);
const availableTypesForSelect = [ruleType, ...otherAvailableTypes];
return (
<ValidationRuleItem key={rule.id} id={rule.id}>
{/* Rule Type Selector */}
<Select
value={ruleType}
onValueChange={(value) => handleRuleTypeChange(rule.id, value as TValidationRuleType)}>
<SelectTrigger className={cn("bg-white", config.needsValue ? "w-[200px]" : "flex-1")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableTypesForSelect.map((type) => (
<SelectItem key={type} value={type}>
{ruleLabels[RULE_TYPE_CONFIG[type].labelKey]}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Value Input (if needed) */}
{config.needsValue && (
<div className="flex w-full items-center gap-2">
<Input
type={config.valueType === "number" ? "number" : "text"}
value={currentValue ?? ""}
onChange={(e) => handleRuleValueChange(rule.id, e.target.value)}
placeholder={config.valuePlaceholder}
className="h-9 min-w-[80px] bg-white"
min={config.valueType === "number" ? 0 : ""}
/>
{/* Unit selector (if applicable) */}
{config.unitOptions && config.unitOptions.length > 0 && (
<Select value={config.unitOptions[0].value}>
<SelectTrigger
className="flex-1 bg-white"
disabled={config.unitOptions.length === 1}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{config.unitOptions.map((unit) => (
<SelectItem key={unit.value} value={unit.value}>
{ruleLabels[unit.labelKey]}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
{/* Delete button */}
<Button
variant="outline"
size="icon"
type="button"
onClick={() => handleDeleteRule(rule.id)}
className="shrink-0 bg-white">
<TrashIcon className="h-4 w-4" />
</Button>
{/* Add button */}
{canAddMore && (
<Button
variant="outline"
size="icon"
type="button"
onClick={() => handleAddRule(index)}
className="shrink-0 bg-white">
<PlusIcon className="h-4 w-4" />
</Button>
)}
</ValidationRuleItem>
);
})}
</div>
</SortableContext>
</DndContext>
</AdvancedOptionToggle>
);
};
@@ -271,6 +271,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
...prismaSurvey, // Properties from prismaSurvey
displayPercentage: Number(prismaSurvey.displayPercentage) || null,
segment: surveySegment,
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
};
return modifiedSurvey;
@@ -0,0 +1,190 @@
import { describe, expect, test } from "vitest";
import { TValidationRuleType } from "@formbricks/types/surveys/validation-rules";
import { RULE_TYPE_CONFIG } from "./validation-rules-config";
describe("RULE_TYPE_CONFIG", () => {
test("should have config for all validation rule types", () => {
const allRuleTypes: TValidationRuleType[] = [
"required",
"minLength",
"maxLength",
"pattern",
"email",
"url",
"phone",
"minValue",
"maxValue",
"minSelections",
"maxSelections",
];
allRuleTypes.forEach((ruleType) => {
expect(RULE_TYPE_CONFIG[ruleType]).toBeDefined();
expect(RULE_TYPE_CONFIG[ruleType].labelKey).toBeDefined();
expect(typeof RULE_TYPE_CONFIG[ruleType].labelKey).toBe("string");
expect(typeof RULE_TYPE_CONFIG[ruleType].needsValue).toBe("boolean");
});
});
describe("required rule", () => {
test("should have correct config", () => {
const config = RULE_TYPE_CONFIG.required;
expect(config.labelKey).toBe("required");
expect(config.needsValue).toBe(false);
expect(config.valueType).toBeUndefined();
expect(config.valuePlaceholder).toBeUndefined();
expect(config.unitOptions).toBeUndefined();
});
});
describe("minLength rule", () => {
test("should have correct config", () => {
const config = RULE_TYPE_CONFIG.minLength;
expect(config.labelKey).toBe("min_length");
expect(config.needsValue).toBe(true);
expect(config.valueType).toBe("number");
expect(config.valuePlaceholder).toBe("100");
expect(config.unitOptions).toEqual([{ value: "characters", labelKey: "characters" }]);
});
});
describe("maxLength rule", () => {
test("should have correct config", () => {
const config = RULE_TYPE_CONFIG.maxLength;
expect(config.labelKey).toBe("max_length");
expect(config.needsValue).toBe(true);
expect(config.valueType).toBe("number");
expect(config.valuePlaceholder).toBe("500");
expect(config.unitOptions).toEqual([{ value: "characters", labelKey: "characters" }]);
});
});
describe("pattern rule", () => {
test("should have correct config", () => {
const config = RULE_TYPE_CONFIG.pattern;
expect(config.labelKey).toBe("pattern");
expect(config.needsValue).toBe(true);
expect(config.valueType).toBe("text");
expect(config.valuePlaceholder).toBe("^[A-Z].*");
expect(config.unitOptions).toBeUndefined();
});
});
describe("email rule", () => {
test("should have correct config", () => {
const config = RULE_TYPE_CONFIG.email;
expect(config.labelKey).toBe("email");
expect(config.needsValue).toBe(false);
expect(config.valueType).toBeUndefined();
expect(config.valuePlaceholder).toBeUndefined();
expect(config.unitOptions).toBeUndefined();
});
});
describe("url rule", () => {
test("should have correct config", () => {
const config = RULE_TYPE_CONFIG.url;
expect(config.labelKey).toBe("url");
expect(config.needsValue).toBe(false);
expect(config.valueType).toBeUndefined();
expect(config.valuePlaceholder).toBeUndefined();
expect(config.unitOptions).toBeUndefined();
});
});
describe("phone rule", () => {
test("should have correct config", () => {
const config = RULE_TYPE_CONFIG.phone;
expect(config.labelKey).toBe("phone");
expect(config.needsValue).toBe(false);
expect(config.valueType).toBeUndefined();
expect(config.valuePlaceholder).toBeUndefined();
expect(config.unitOptions).toBeUndefined();
});
});
describe("minValue rule", () => {
test("should have correct config", () => {
const config = RULE_TYPE_CONFIG.minValue;
expect(config.labelKey).toBe("min_value");
expect(config.needsValue).toBe(true);
expect(config.valueType).toBe("number");
expect(config.valuePlaceholder).toBe("0");
expect(config.unitOptions).toBeUndefined();
});
});
describe("maxValue rule", () => {
test("should have correct config", () => {
const config = RULE_TYPE_CONFIG.maxValue;
expect(config.labelKey).toBe("max_value");
expect(config.needsValue).toBe(true);
expect(config.valueType).toBe("number");
expect(config.valuePlaceholder).toBe("100");
expect(config.unitOptions).toBeUndefined();
});
});
describe("minSelections rule", () => {
test("should have correct config", () => {
const config = RULE_TYPE_CONFIG.minSelections;
expect(config.labelKey).toBe("min_selections");
expect(config.needsValue).toBe(true);
expect(config.valueType).toBe("number");
expect(config.valuePlaceholder).toBe("1");
expect(config.unitOptions).toEqual([{ value: "options", labelKey: "options_selected" }]);
});
});
describe("maxSelections rule", () => {
test("should have correct config", () => {
const config = RULE_TYPE_CONFIG.maxSelections;
expect(config.labelKey).toBe("max_selections");
expect(config.needsValue).toBe(true);
expect(config.valueType).toBe("number");
expect(config.valuePlaceholder).toBe("3");
expect(config.unitOptions).toEqual([{ value: "options", labelKey: "options_selected" }]);
});
});
describe("valueType validation", () => {
test("should have valueType 'number' for numeric rules", () => {
expect(RULE_TYPE_CONFIG.minLength.valueType).toBe("number");
expect(RULE_TYPE_CONFIG.maxLength.valueType).toBe("number");
expect(RULE_TYPE_CONFIG.minValue.valueType).toBe("number");
expect(RULE_TYPE_CONFIG.maxValue.valueType).toBe("number");
expect(RULE_TYPE_CONFIG.minSelections.valueType).toBe("number");
expect(RULE_TYPE_CONFIG.maxSelections.valueType).toBe("number");
});
test("should have valueType 'text' for text rules", () => {
expect(RULE_TYPE_CONFIG.pattern.valueType).toBe("text");
});
test("should not have valueType for rules that don't need values", () => {
expect(RULE_TYPE_CONFIG.required.valueType).toBeUndefined();
expect(RULE_TYPE_CONFIG.email.valueType).toBeUndefined();
expect(RULE_TYPE_CONFIG.url.valueType).toBeUndefined();
expect(RULE_TYPE_CONFIG.phone.valueType).toBeUndefined();
});
});
describe("unitOptions validation", () => {
test("should have unitOptions for length and selection rules", () => {
expect(RULE_TYPE_CONFIG.minLength.unitOptions).toBeDefined();
expect(RULE_TYPE_CONFIG.maxLength.unitOptions).toBeDefined();
expect(RULE_TYPE_CONFIG.minSelections.unitOptions).toBeDefined();
expect(RULE_TYPE_CONFIG.maxSelections.unitOptions).toBeDefined();
});
test("should not have unitOptions for other rules", () => {
expect(RULE_TYPE_CONFIG.required.unitOptions).toBeUndefined();
expect(RULE_TYPE_CONFIG.pattern.unitOptions).toBeUndefined();
expect(RULE_TYPE_CONFIG.email.unitOptions).toBeUndefined();
expect(RULE_TYPE_CONFIG.url.unitOptions).toBeUndefined();
expect(RULE_TYPE_CONFIG.phone.unitOptions).toBeUndefined();
expect(RULE_TYPE_CONFIG.minValue.unitOptions).toBeUndefined();
expect(RULE_TYPE_CONFIG.maxValue.unitOptions).toBeUndefined();
});
});
});
@@ -0,0 +1,76 @@
import { TValidationRuleType } from "@formbricks/types/surveys/validation-rules";
// Rule type definitions with i18n keys
export const RULE_TYPE_CONFIG: Record<
TValidationRuleType,
{
labelKey: string;
needsValue: boolean;
valueType?: "number" | "text";
valuePlaceholder?: string;
unitOptions?: { value: string; labelKey: string }[];
}
> = {
required: {
labelKey: "required",
needsValue: false,
},
minLength: {
labelKey: "min_length",
needsValue: true,
valueType: "number",
valuePlaceholder: "100",
unitOptions: [{ value: "characters", labelKey: "characters" }],
},
maxLength: {
labelKey: "max_length",
needsValue: true,
valueType: "number",
valuePlaceholder: "500",
unitOptions: [{ value: "characters", labelKey: "characters" }],
},
pattern: {
labelKey: "pattern",
needsValue: true,
valueType: "text",
valuePlaceholder: "^[A-Z].*",
},
email: {
labelKey: "email",
needsValue: false,
},
url: {
labelKey: "url",
needsValue: false,
},
phone: {
labelKey: "phone",
needsValue: false,
},
minValue: {
labelKey: "min_value",
needsValue: true,
valueType: "number",
valuePlaceholder: "0",
},
maxValue: {
labelKey: "max_value",
needsValue: true,
valueType: "number",
valuePlaceholder: "100",
},
minSelections: {
labelKey: "min_selections",
needsValue: true,
valueType: "number",
valuePlaceholder: "1",
unitOptions: [{ value: "options", labelKey: "options_selected" }],
},
maxSelections: {
labelKey: "max_selections",
needsValue: true,
valueType: "number",
valuePlaceholder: "3",
unitOptions: [{ value: "options", labelKey: "options_selected" }],
},
};
@@ -0,0 +1,486 @@
import { describe, expect, test } from "vitest";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TValidationRule } from "@formbricks/types/surveys/validation-rules";
import { createRuleParams, getAvailableRuleTypes, getRuleValue } from "./validation-rules-utils";
describe("getAvailableRuleTypes", () => {
test("should return all applicable rules for openText element when no rules exist", () => {
const elementType = TSurveyElementTypeEnum.OpenText;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toContain("required");
expect(available).toContain("minLength");
expect(available).toContain("maxLength");
expect(available).toContain("pattern");
expect(available).toContain("email");
expect(available).toContain("url");
expect(available).toContain("phone");
expect(available).toContain("minValue");
expect(available).toContain("maxValue");
});
test("should filter out already added rules", () => {
const elementType = TSurveyElementTypeEnum.OpenText;
const existingRules: TValidationRule[] = [
{
id: "rule1",
type: "required",
params: {},
},
{
id: "rule2",
type: "minLength",
params: { min: 10 },
},
];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).not.toContain("required");
expect(available).not.toContain("minLength");
expect(available).toContain("maxLength");
expect(available).toContain("pattern");
});
test("should return only required rule for multipleChoiceSingle element", () => {
const elementType = TSurveyElementTypeEnum.MultipleChoiceSingle;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual(["required"]);
});
test("should return empty array for multipleChoiceSingle when required is already added", () => {
const elementType = TSurveyElementTypeEnum.MultipleChoiceSingle;
const existingRules: TValidationRule[] = [
{
id: "rule1",
type: "required",
params: {},
},
];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual([]);
});
test("should return required, minSelections, maxSelections for multipleChoiceMulti element", () => {
const elementType = TSurveyElementTypeEnum.MultipleChoiceMulti;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toContain("required");
expect(available).toContain("minSelections");
expect(available).toContain("maxSelections");
expect(available.length).toBe(3);
});
test("should return only required rule for rating element", () => {
const elementType = TSurveyElementTypeEnum.Rating;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual(["required"]);
});
test("should return only required rule for nps element", () => {
const elementType = TSurveyElementTypeEnum.NPS;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual(["required"]);
});
test("should return only required rule for date element", () => {
const elementType = TSurveyElementTypeEnum.Date;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual(["required"]);
});
test("should return only required rule for consent element", () => {
const elementType = TSurveyElementTypeEnum.Consent;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual(["required"]);
});
test("should return only required rule for matrix element", () => {
const elementType = TSurveyElementTypeEnum.Matrix;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual(["required"]);
});
test("should return only required rule for ranking element", () => {
const elementType = TSurveyElementTypeEnum.Ranking;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual(["required"]);
});
test("should return only required rule for fileUpload element", () => {
const elementType = TSurveyElementTypeEnum.FileUpload;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual(["required"]);
});
test("should return required, minSelections, maxSelections for pictureSelection element", () => {
const elementType = TSurveyElementTypeEnum.PictureSelection;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toContain("required");
expect(available).toContain("minSelections");
expect(available).toContain("maxSelections");
expect(available.length).toBe(3);
});
test("should return only required rule for address element", () => {
const elementType = TSurveyElementTypeEnum.Address;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual(["required"]);
});
test("should return only required rule for contactInfo element", () => {
const elementType = TSurveyElementTypeEnum.ContactInfo;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual(["required"]);
});
test("should return only required rule for cal element", () => {
const elementType = TSurveyElementTypeEnum.Cal;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual(["required"]);
});
test("should return empty array for cta element", () => {
const elementType = TSurveyElementTypeEnum.CTA;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual([]);
});
test("should handle unknown element type gracefully", () => {
const elementType = "unknown" as TSurveyElementTypeEnum;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual([]);
});
});
describe("getRuleValue", () => {
test("should return min value for minLength rule", () => {
const rule: TValidationRule = {
id: "rule1",
type: "minLength",
params: { min: 10 },
};
expect(getRuleValue(rule)).toBe(10);
});
test("should return max value for maxLength rule", () => {
const rule: TValidationRule = {
id: "rule2",
type: "maxLength",
params: { max: 100 },
};
expect(getRuleValue(rule)).toBe(100);
});
test("should return pattern string for pattern rule", () => {
const rule: TValidationRule = {
id: "rule3",
type: "pattern",
params: { pattern: "^[A-Z].*" },
};
expect(getRuleValue(rule)).toBe("^[A-Z].*");
});
test("should return pattern string with flags for pattern rule", () => {
const rule: TValidationRule = {
id: "rule3",
type: "pattern",
params: { pattern: "^[A-Z].*", flags: "i" },
};
expect(getRuleValue(rule)).toBe("^[A-Z].*");
});
test("should return min value for minValue rule", () => {
const rule: TValidationRule = {
id: "rule4",
type: "minValue",
params: { min: 5 },
};
expect(getRuleValue(rule)).toBe(5);
});
test("should return max value for maxValue rule", () => {
const rule: TValidationRule = {
id: "rule5",
type: "maxValue",
params: { max: 50 },
};
expect(getRuleValue(rule)).toBe(50);
});
test("should return min value for minSelections rule", () => {
const rule: TValidationRule = {
id: "rule6",
type: "minSelections",
params: { min: 2 },
};
expect(getRuleValue(rule)).toBe(2);
});
test("should return max value for maxSelections rule", () => {
const rule: TValidationRule = {
id: "rule7",
type: "maxSelections",
params: { max: 5 },
};
expect(getRuleValue(rule)).toBe(5);
});
test("should return undefined for required rule", () => {
const rule: TValidationRule = {
id: "rule8",
type: "required",
params: {},
};
expect(getRuleValue(rule)).toBeUndefined();
});
test("should return undefined for email rule", () => {
const rule: TValidationRule = {
id: "rule9",
type: "email",
params: {},
};
expect(getRuleValue(rule)).toBeUndefined();
});
test("should return undefined for url rule", () => {
const rule: TValidationRule = {
id: "rule10",
type: "url",
params: {},
};
expect(getRuleValue(rule)).toBeUndefined();
});
test("should return undefined for phone rule", () => {
const rule: TValidationRule = {
id: "rule11",
type: "phone",
params: {},
};
expect(getRuleValue(rule)).toBeUndefined();
});
test("should return empty string for pattern rule with empty pattern", () => {
const rule: TValidationRule = {
id: "rule12",
type: "pattern",
params: { pattern: "" },
};
expect(getRuleValue(rule)).toBe("");
});
});
describe("createRuleParams", () => {
test("should create empty params for required rule", () => {
const params = createRuleParams("required");
expect(params).toEqual({});
});
test("should create params for minLength rule with value", () => {
const params = createRuleParams("minLength", 10);
expect(params).toEqual({ min: 10 });
});
test("should create params for minLength rule without value (defaults to 0)", () => {
const params = createRuleParams("minLength");
expect(params).toEqual({ min: 0 });
});
test("should create params for maxLength rule with value", () => {
const params = createRuleParams("maxLength", 100);
expect(params).toEqual({ max: 100 });
});
test("should create params for maxLength rule without value (defaults to 100)", () => {
const params = createRuleParams("maxLength");
expect(params).toEqual({ max: 100 });
});
test("should create params for pattern rule with string value", () => {
const params = createRuleParams("pattern", "^[A-Z].*");
expect(params).toEqual({ pattern: "^[A-Z].*" });
});
test("should create params for pattern rule without value (defaults to empty string)", () => {
const params = createRuleParams("pattern");
expect(params).toEqual({ pattern: "" });
});
test("should create empty params for email rule", () => {
const params = createRuleParams("email");
expect(params).toEqual({});
});
test("should create empty params for url rule", () => {
const params = createRuleParams("url");
expect(params).toEqual({});
});
test("should create empty params for phone rule", () => {
const params = createRuleParams("phone");
expect(params).toEqual({});
});
test("should create params for minValue rule with value", () => {
const params = createRuleParams("minValue", 5);
expect(params).toEqual({ min: 5 });
});
test("should create params for minValue rule without value (defaults to 0)", () => {
const params = createRuleParams("minValue");
expect(params).toEqual({ min: 0 });
});
test("should create params for maxValue rule with value", () => {
const params = createRuleParams("maxValue", 50);
expect(params).toEqual({ max: 50 });
});
test("should create params for maxValue rule without value (defaults to 100)", () => {
const params = createRuleParams("maxValue");
expect(params).toEqual({ max: 100 });
});
test("should create params for minSelections rule with value", () => {
const params = createRuleParams("minSelections", 2);
expect(params).toEqual({ min: 2 });
});
test("should create params for minSelections rule without value (defaults to 1)", () => {
const params = createRuleParams("minSelections");
expect(params).toEqual({ min: 1 });
});
test("should create params for maxSelections rule with value", () => {
const params = createRuleParams("maxSelections", 5);
expect(params).toEqual({ max: 5 });
});
test("should create params for maxSelections rule without value (defaults to 3)", () => {
const params = createRuleParams("maxSelections");
expect(params).toEqual({ max: 3 });
});
test("should convert string number to number for minLength", () => {
const params = createRuleParams("minLength", "10");
expect(params).toEqual({ min: 10 });
});
test("should convert string number to number for maxLength", () => {
const params = createRuleParams("maxLength", "100");
expect(params).toEqual({ max: 100 });
});
test("should convert string number to number for minValue", () => {
const params = createRuleParams("minValue", "5");
expect(params).toEqual({ min: 5 });
});
test("should convert string number to number for maxValue", () => {
const params = createRuleParams("maxValue", "50");
expect(params).toEqual({ max: 50 });
});
test("should convert string number to number for minSelections", () => {
const params = createRuleParams("minSelections", "2");
expect(params).toEqual({ min: 2 });
});
test("should convert string number to number for maxSelections", () => {
const params = createRuleParams("maxSelections", "5");
expect(params).toEqual({ max: 5 });
});
test("should handle invalid string number (defaults to 0 for minLength)", () => {
const params = createRuleParams("minLength", "invalid");
expect(params).toEqual({ min: 0 });
});
test("should handle invalid string number (defaults to 100 for maxLength)", () => {
const params = createRuleParams("maxLength", "invalid");
expect(params).toEqual({ max: 100 });
});
test("should handle invalid string number (defaults to 0 for minValue)", () => {
const params = createRuleParams("minValue", "invalid");
expect(params).toEqual({ min: 0 });
});
test("should handle invalid string number (defaults to 100 for maxValue)", () => {
const params = createRuleParams("maxValue", "invalid");
expect(params).toEqual({ max: 100 });
});
test("should handle invalid string number (defaults to 1 for minSelections)", () => {
const params = createRuleParams("minSelections", "invalid");
expect(params).toEqual({ min: 1 });
});
test("should handle invalid string number (defaults to 3 for maxSelections)", () => {
const params = createRuleParams("maxSelections", "invalid");
expect(params).toEqual({ max: 3 });
});
});
@@ -0,0 +1,74 @@
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import {
APPLICABLE_RULES,
TValidationRule,
TValidationRuleType,
} from "@formbricks/types/surveys/validation-rules";
/**
* Get available rule types for an element type, excluding already added rules
*/
export const getAvailableRuleTypes = (
elementType: TSurveyElementTypeEnum,
existingRules: TValidationRule[]
): TValidationRuleType[] => {
const elementTypeKey = elementType.toString();
const applicable = APPLICABLE_RULES[elementTypeKey] ?? [];
// Filter out rules that are already added (for non-repeatable rules)
const existingTypes = new Set(existingRules.map((r) => r.type));
return applicable.filter((ruleType) => {
// Allow only one of each rule type
return !existingTypes.has(ruleType);
});
};
/**
* Get the value from rule params based on rule type
*/
export const getRuleValue = (rule: TValidationRule): number | string | undefined => {
const params = rule.params as Record<string, unknown>;
if ("min" in params) return params.min as number;
if ("max" in params) return params.max as number;
if ("pattern" in params) {
const pattern = params.pattern as string;
return pattern ?? "";
}
return undefined;
};
/**
* Create params object from rule type and value (without type field)
*/
export const createRuleParams = (
ruleType: TValidationRuleType,
value?: number | string
): TValidationRule["params"] => {
switch (ruleType) {
case "required":
return {};
case "minLength":
return { min: Number(value) || 0 };
case "maxLength":
return { max: Number(value) || 100 };
case "pattern":
return { pattern: value === undefined || value === null ? "" : String(value) };
case "email":
return {};
case "url":
return {};
case "phone":
return {};
case "minValue":
return { min: Number(value) || 0 };
case "maxValue":
return { max: Number(value) || 100 };
case "minSelections":
return { min: Number(value) || 1 };
case "maxSelections":
return { max: Number(value) || 3 };
default:
return {};
}
};
+2
View File
@@ -40,6 +40,8 @@ export const selectSurvey = {
isBackButtonHidden: true,
metadata: true,
slug: true,
customHeadScripts: true,
customHeadScriptsMode: true,
languages: {
select: {
default: true,
+1
View File
@@ -20,6 +20,7 @@ export const transformPrismaSurvey = <T extends TSurvey | TJsEnvironmentStateSur
...surveyPrisma,
displayPercentage: Number(surveyPrisma.displayPercentage) || null,
segment,
customHeadScriptsMode: surveyPrisma.customHeadScriptsMode,
} as T;
return transformedSurvey;
@@ -0,0 +1,82 @@
"use client";
import { useEffect, useRef } from "react";
import { TSurvey } from "@formbricks/types/surveys/types";
interface CustomScriptsInjectorProps {
projectScripts?: string | null;
surveyScripts?: string | null;
scriptsMode?: TSurvey["customHeadScriptsMode"];
}
/**
* Injects custom HTML scripts into the document head for link surveys.
* Supports merging project and survey scripts or replacing project scripts with survey scripts.
*
* @param projectScripts - Scripts configured at the workspace/project level
* @param surveyScripts - Scripts configured at the survey level
* @param scriptsMode - "add" merges both, "replace" uses only survey scripts
*/
export const CustomScriptsInjector = ({
projectScripts,
surveyScripts,
scriptsMode,
}: CustomScriptsInjectorProps) => {
const injectedRef = useRef(false);
useEffect(() => {
// Prevent double injection in React strict mode
if (injectedRef.current) return;
// Determine which scripts to inject based on mode
let scriptsToInject: string;
if (scriptsMode === "replace" && surveyScripts) {
// Replace mode: only use survey scripts
scriptsToInject = surveyScripts;
} else {
// Add mode (default): merge project and survey scripts
scriptsToInject = [projectScripts, surveyScripts].filter(Boolean).join("\n");
}
if (!scriptsToInject.trim()) return;
try {
// Create a temporary container to parse the HTML
const container = document.createElement("div");
container.innerHTML = scriptsToInject;
// Process and inject script elements
const scripts = container.querySelectorAll("script");
scripts.forEach((script) => {
const newScript = document.createElement("script");
// Copy all attributes (src, async, defer, type, etc.)
Array.from(script.attributes).forEach((attr) => {
newScript.setAttribute(attr.name, attr.value);
});
// Copy inline script content
if (script.textContent) {
newScript.textContent = script.textContent;
}
document.head.appendChild(newScript);
});
// Process and inject non-script elements (noscript, meta, link, style, etc.)
const nonScripts = container.querySelectorAll(":not(script)");
nonScripts.forEach((el) => {
const clonedEl = el.cloneNode(true) as Element;
document.head.appendChild(clonedEl);
});
injectedRef.current = true;
} catch (error) {
// Log error but don't break the survey - self-hosted admins can check console
console.warn("[Formbricks] Error injecting custom scripts:", error);
}
}, [projectScripts, surveyScripts, scriptsMode]);
return null;
};
@@ -13,7 +13,7 @@ import { OTPInput } from "@/modules/ui/components/otp-input";
interface PinScreenProps {
surveyId: string;
project: Pick<Project, "styling" | "logo" | "linkSurveyBranding">;
project: Pick<Project, "styling" | "logo" | "linkSurveyBranding" | "customHeadScripts">;
singleUseId?: string;
singleUseResponse?: Pick<Response, "id" | "finished">;
publicDomain: string;
@@ -7,13 +7,14 @@ import { TProjectStyling } from "@formbricks/types/project";
import { TResponseData } from "@formbricks/types/responses";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { CustomScriptsInjector } from "@/modules/survey/link/components/custom-scripts-injector";
import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-wrapper";
import { getPrefillValue } from "@/modules/survey/link/lib/prefill";
import { SurveyInline } from "@/modules/ui/components/survey";
interface SurveyClientWrapperProps {
survey: TSurvey;
project: Pick<Project, "styling" | "logo" | "linkSurveyBranding">;
project: Pick<Project, "styling" | "logo" | "linkSurveyBranding" | "customHeadScripts">;
styling: TProjectStyling | TSurveyStyling;
publicDomain: string;
responseCount?: number;
@@ -117,52 +118,62 @@ export const SurveyClientWrapper = ({
};
return (
<LinkSurveyWrapper
project={project}
surveyId={survey.id}
isWelcomeCardEnabled={survey.welcomeCard.enabled}
isPreview={isPreview}
surveyType={survey.type}
determineStyling={() => styling}
handleResetSurvey={handleResetSurvey}
isEmbed={isEmbed}
publicDomain={publicDomain}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
isBrandingEnabled={project.linkSurveyBranding}>
<SurveyInline
appUrl={publicDomain}
environmentId={survey.environmentId}
isPreviewMode={isPreview}
survey={survey}
styling={styling}
languageCode={languageCode}
isBrandingEnabled={project.linkSurveyBranding}
shouldResetQuestionId={false}
autoFocus={autoFocus}
prefillResponseData={prefillValue}
skipPrefilled={skipPrefilled}
responseCount={responseCount}
getSetBlockId={(f: (value: string) => void) => {
setBlockId = f;
}}
getSetResponseData={(f: (value: TResponseData) => void) => {
setResponseData = f;
}}
startAtQuestionId={startAt && isStartAtValid ? startAt : undefined}
fullSizeCards={isEmbed}
hiddenFieldsRecord={{
...hiddenFieldsRecord,
...getVerifiedEmail,
}}
singleUseId={singleUseId}
singleUseResponseId={singleUseResponseId}
getSetIsResponseSendingFinished={(_f: (value: boolean) => void) => {}}
contactId={contactId}
recaptchaSiteKey={recaptchaSiteKey}
isSpamProtectionEnabled={isSpamProtectionEnabled}
/>
</LinkSurveyWrapper>
<>
{/* Inject custom scripts for tracking/analytics (self-hosted only) */}
{!IS_FORMBRICKS_CLOUD && !isPreview && (
<CustomScriptsInjector
projectScripts={project.customHeadScripts}
surveyScripts={survey.customHeadScripts}
scriptsMode={survey.customHeadScriptsMode}
/>
)}
<LinkSurveyWrapper
project={project}
surveyId={survey.id}
isWelcomeCardEnabled={survey.welcomeCard.enabled}
isPreview={isPreview}
surveyType={survey.type}
determineStyling={() => styling}
handleResetSurvey={handleResetSurvey}
isEmbed={isEmbed}
publicDomain={publicDomain}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
isBrandingEnabled={project.linkSurveyBranding}>
<SurveyInline
appUrl={publicDomain}
environmentId={survey.environmentId}
isPreviewMode={isPreview}
survey={survey}
styling={styling}
languageCode={languageCode}
isBrandingEnabled={project.linkSurveyBranding}
shouldResetQuestionId={false}
autoFocus={autoFocus}
prefillResponseData={prefillValue}
skipPrefilled={skipPrefilled}
responseCount={responseCount}
getSetBlockId={(f: (value: string) => void) => {
setBlockId = f;
}}
getSetResponseData={(f: (value: TResponseData) => void) => {
setResponseData = f;
}}
startAtQuestionId={startAt && isStartAtValid ? startAt : undefined}
fullSizeCards={isEmbed}
hiddenFieldsRecord={{
...hiddenFieldsRecord,
...getVerifiedEmail,
}}
singleUseId={singleUseId}
singleUseResponseId={singleUseResponseId}
getSetIsResponseSendingFinished={(_f: (value: boolean) => void) => {}}
contactId={contactId}
recaptchaSiteKey={recaptchaSiteKey}
isSpamProtectionEnabled={isSpamProtectionEnabled}
/>
</LinkSurveyWrapper>
</>
);
};
+4
View File
@@ -60,6 +60,10 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
recaptcha: true,
metadata: true,
// Custom scripts (self-hosted only)
customHeadScripts: true,
customHeadScriptsMode: true,
// Related data
languages: {
select: {
@@ -85,6 +85,7 @@ describe("getEnvironmentContextForLinkSurvey", () => {
styling: true,
logo: true,
linkSurveyBranding: true,
customHeadScripts: true,
organizationId: true,
organization: {
select: {
@@ -16,7 +16,10 @@ import { validateInputs } from "@/lib/utils/validate";
* deduplication within the same render cycle.
*/
type TProjectForLinkSurvey = Pick<Project, "id" | "name" | "styling" | "logo" | "linkSurveyBranding">;
type TProjectForLinkSurvey = Pick<
Project,
"id" | "name" | "styling" | "logo" | "linkSurveyBranding" | "customHeadScripts"
>;
export interface TEnvironmentContextForLinkSurvey {
project: TProjectForLinkSurvey;
@@ -61,6 +64,7 @@ export const getEnvironmentContextForLinkSurvey = reactCache(
styling: true,
logo: true,
linkSurveyBranding: true,
customHeadScripts: true,
organizationId: true,
organization: {
select: {
@@ -91,6 +95,7 @@ export const getEnvironmentContextForLinkSurvey = reactCache(
styling: environment.project.styling,
logo: environment.project.logo,
linkSurveyBranding: environment.project.linkSurveyBranding,
customHeadScripts: environment.project.customHeadScripts,
},
organizationId: environment.project.organizationId,
organizationBilling: environment.project.organization.billing,
@@ -61,6 +61,7 @@ describe("getProjectByEnvironmentId", () => {
},
},
select: {
customHeadScripts: true,
linkSurveyBranding: true,
logo: true,
styling: true,
+5 -1
View File
@@ -10,7 +10,10 @@ import { validateInputs } from "@/lib/utils/validate";
export const getProjectByEnvironmentId = reactCache(
async (
environmentId: string
): Promise<Pick<Project, "styling" | "logo" | "linkSurveyBranding" | "name"> | null> => {
): Promise<Pick<
Project,
"styling" | "logo" | "linkSurveyBranding" | "name" | "customHeadScripts"
> | null> => {
validateInputs([environmentId, ZId]);
let projectPrisma;
@@ -29,6 +32,7 @@ export const getProjectByEnvironmentId = reactCache(
logo: true,
linkSurveyBranding: true,
name: true,
customHeadScripts: true,
},
});
@@ -2,7 +2,6 @@
import { Table } from "@tanstack/react-table";
import { MoveVerticalIcon, RefreshCcwIcon, SettingsIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
@@ -20,6 +19,7 @@ interface DataTableToolbarProps<T> {
downloadRowsAction?: (rowIds: string[], format: string) => Promise<void>;
isQuotasAllowed: boolean;
leftContent?: React.ReactNode;
onRefresh?: () => Promise<void>;
}
export const DataTableToolbar = <T,>({
@@ -33,9 +33,9 @@ export const DataTableToolbar = <T,>({
downloadRowsAction,
isQuotasAllowed,
leftContent,
onRefresh,
}: DataTableToolbarProps<T>) => {
const { t } = useTranslation();
const router = useRouter();
return (
<div className="sticky top-0 z-30 flex w-full items-center justify-between bg-slate-50 py-2">
@@ -52,13 +52,13 @@ export const DataTableToolbar = <T,>({
<div>{leftContent}</div>
)}
<div className="flex space-x-2">
{type === "contact" ? (
{type === "contact" && onRefresh ? (
<TooltipRenderer
tooltipContent={t("environments.contacts.contacts_table_refresh")}
shouldRender={true}>
<button
onClick={async () => {
router.refresh();
await onRefresh();
toast.success(t("environments.contacts.contacts_table_refresh_success"));
}}
className="cursor-pointer rounded-md border bg-white hover:border-slate-400">
+18 -7
View File
@@ -133,15 +133,32 @@ const nextConfig = {
const isProduction = process.env.NODE_ENV === "production";
const scriptSrcUnsafeEval = isProduction ? "" : " 'unsafe-eval'";
const cspBase = `default-src 'self'; script-src 'self' 'unsafe-inline'${scriptSrcUnsafeEval} https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' blob: data: http://localhost:9000 https:; font-src 'self' data: https:; connect-src 'self' http://localhost:9000 https: wss:; frame-src 'self' https://app.cal.com https:; media-src 'self' https:; object-src 'self' data: https:; base-uri 'self'; form-action 'self'`;
return [
{
// Apply X-Frame-Options to all routes except those starting with /s/ or /c/
// Apply X-Frame-Options and restricted frame-ancestors to all routes except those starting with /s/ or /c/
source: "/((?!s/|c/).*)",
headers: [
{
key: "X-Frame-Options",
value: "SAMEORIGIN",
},
{
key: "Content-Security-Policy",
value: `${cspBase}; frame-ancestors 'self'`,
},
],
},
{
// Allow surveys (/s/*) and contact survey links (/c/*) to be embedded in iframes on any domain
// Note: These routes need frame-ancestors * to support embedding surveys in customer websites
source: "/(s|c)/:path*",
headers: [
{
key: "Content-Security-Policy",
value: `${cspBase}; frame-ancestors *`,
},
],
},
{
@@ -179,10 +196,6 @@ const nextConfig = {
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Content-Security-Policy",
value: `default-src 'self'; script-src 'self' 'unsafe-inline'${scriptSrcUnsafeEval} https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' blob: data: http://localhost:9000 https:; font-src 'self' data: https:; connect-src 'self' http://localhost:9000 https: wss:; frame-src 'self' https://app.cal.com https:; media-src 'self' https:; object-src 'self' data: https:; base-uri 'self'; form-action 'self'`,
},
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
@@ -458,7 +471,5 @@ const sentryOptions = {
// Runtime Sentry reporting still depends on DSN being set via environment variables
const exportConfig = process.env.SENTRY_AUTH_TOKEN ? withSentryConfig(nextConfig, sentryOptions) : nextConfig;
console.log("BASE PATH", nextConfig.basePath);
export default exportConfig;
+1 -1
View File
@@ -16,7 +16,7 @@
"generate-api-specs": "./scripts/openapi/generate.sh",
"merge-client-endpoints": "tsx ./scripts/openapi/merge-client-endpoints.ts",
"generate-and-merge-api-specs": "pnpm run generate-api-specs && pnpm run merge-client-endpoints",
"i18n:generate": "npx lingo.dev@latest i18n"
"i18n:generate": "npx lingo.dev@latest run && npx lingo.dev@latest lockfile --force"
},
"dependencies": {
"@aws-sdk/client-s3": "3.879.0",
+2 -1
View File
@@ -101,7 +101,8 @@
"xm-and-surveys/surveys/link-surveys/start-at-question",
"xm-and-surveys/surveys/link-surveys/verify-email-before-survey",
"xm-and-surveys/surveys/link-surveys/market-research-panel",
"xm-and-surveys/surveys/link-surveys/pin-protected-surveys"
"xm-and-surveys/surveys/link-surveys/pin-protected-surveys",
"xm-and-surveys/surveys/link-surveys/custom-head-scripts"
]
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

@@ -0,0 +1,193 @@
---
title: "Custom Head Scripts"
description: "Add tracking pixels, analytics, or custom code to your link surveys for self-hosted instances."
icon: "code"
---
Custom Head Scripts allow you to inject custom HTML code into the `<head>` section of your **Link Surveys**. This is useful for adding tracking pixels, analytics scripts, chatbots, or any other third-party code.
<Note>
Custom Head Scripts is only available for **Link Surveys on self-hosted instances**. This feature is not available for Website & App Surveys or on Formbricks Cloud.
</Note>
## When to Use Custom Head Scripts
Use Custom Head Scripts when you need to:
- Add analytics tools (Google Analytics, Plausible, Mixpanel, etc.)
- Integrate tracking pixels (Facebook Pixel, LinkedIn Insight Tag, etc.)
- Include custom JavaScript for advanced survey behavior
- Add third-party widgets or chatbots
- Inject custom meta tags or stylesheets
## Configuration Guide
### Workspace-Level Scripts
These scripts apply to **all link surveys** in your workspace.
<Frame>
<img src="/images/xm-and-surveys/surveys/link-surveys/custom-head-scripts/workspace-setting.webp" alt="Custom Scripts in Workspace Settings" />
</Frame>
<Steps>
<Step title="Navigate to Workspace Settings">
Go to your Workspace Settings from the main navigation menu.
</Step>
<Step title="Locate Custom Scripts Section">
Scroll down to the **Custom Scripts** card in the General settings.
</Step>
<Step title="Add Your Scripts">
Paste your HTML code into the text area. You can include:
- `<script>` tags (inline or with `src` attribute)
- `<meta>` tags
- `<link>` tags for stylesheets
- `<style>` tags
- `<noscript>` tags
**Example:**
```html
<!-- Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'GA_MEASUREMENT_ID');
</script>
```
</Step>
<Step title="Save Your Changes">
Click the **Save** button to apply your custom scripts to all link surveys.
</Step>
</Steps>
### Survey-Level Scripts
Override or extend workspace scripts for **specific surveys**.
<Frame>
<img src="/images/xm-and-surveys/surveys/link-surveys/custom-head-scripts/share-survey-modal-setting.webp" alt="Custom HTML tab in Share Survey Modal" />
</Frame>
<Steps>
<Step title="Open Share Survey Modal">
Navigate to your survey and click the **Share** button to open the share modal.
</Step>
<Step title="Go to Custom HTML Tab">
In the share modal, click on the **Custom HTML** tab under the "Share Settings" section.
</Step>
<Step title="Choose Script Mode">
Select how you want survey scripts to interact with workspace scripts:
- **Add to Workspace Scripts** - Survey scripts will be added **after** workspace scripts (both run)
- **Replace Workspace Scripts** - Survey scripts will **replace** workspace scripts entirely (only survey scripts run)
</Step>
<Step title="Add Survey-Specific Scripts">
Paste your HTML code into the "Survey Scripts" text area.
**Example** (Facebook Pixel for a specific campaign):
```html
<!-- Facebook Pixel for Campaign X -->
<script>
fbq('track', 'ViewContent', {
content_name: 'Customer Satisfaction Survey',
campaign: 'Q1-2024'
});
</script>
```
</Step>
<Step title="Save Changes">
Click **Save** to apply the survey-specific scripts.
</Step>
</Steps>
## To keep in mind
<Warning>
Custom scripts execute in the context of your survey pages. Only add scripts from trusted sources. Malicious scripts could compromise your survey data or user privacy.
</Warning>
- **Scripts Don't Load in Preview Mode** — Custom Head Scripts are not loaded in preview mode (editor preview or `?preview=true`). This prevents analytics tracking and pixel triggers during testing. To test your scripts, publish your survey and view it through the actual link survey URL without the preview parameter.
- **Link Surveys Only** — Custom Head Scripts only work with link surveys. They are not available for app/website surveys, as those are embedded in your application and should use your application's existing script management.
- **Self-Hosted Only** — This feature is only available on self-hosted instances. Formbricks Cloud does not support Custom Head Scripts for security and performance reasons.
- **Permissions** — Only Owners, Managers, and members with Manage access can configure Custom Head Scripts.
## Common Use Cases
### Global Analytics (Workspace Level)
Set up Google Analytics for all surveys in your workspace:
```html
<script async src="https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'GA_MEASUREMENT_ID');
</script>
```
### Campaign-Specific Tracking (Survey Level with Add Mode)
Add Facebook Pixel tracking for a specific marketing campaign:
**Workspace Scripts:** Google Analytics (as above)
**Survey Scripts:**
```html
<script>
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', 'YOUR_PIXEL_ID');
fbq('track', 'PageView');
</script>
```
**Result:** Both Google Analytics and Facebook Pixel run on this survey.
### Custom Fonts (Workspace Level)
Load custom fonts for all your surveys:
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Inter', sans-serif;
}
</style>
```
## Troubleshooting
If your scripts aren't loading, check:
1. **Is it a self-hosted instance?** Custom scripts only work on self-hosted Formbricks.
2. **Are you in preview mode?** Scripts don't load in preview—test with the actual survey link.
3. **Check the browser console** for JavaScript errors that might prevent script execution.
4. **Verify your HTML syntax** is correct (properly closed tags, valid attributes).
+2
View File
@@ -18,6 +18,8 @@
"db:migrate:deploy": "turbo run db:migrate:deploy",
"db:start": "turbo run db:start",
"db:push": "turbo run db:push",
"db:seed": "turbo run db:seed",
"db:seed:clear": "turbo run db:seed -- -- --clear",
"db:up": "docker compose -f docker-compose.dev.yml up -d",
"db:down": "docker compose -f docker-compose.dev.yml down",
"go": "pnpm db:up && turbo run go --concurrency 20",
+37 -1
View File
@@ -82,6 +82,12 @@ Run these commands from the root directory of the Formbricks monorepo:
- Generates new `migration.sql` in the custom directory
- Copies migration to Prisma's internal directory
- Applies all pending migrations to the database
- **`pnpm db:seed`**: Seed the database with sample data
- Upserts base infrastructure (Organization, Project, Environments)
- Creates multi-role users (Admin, Manager)
- Generates complex surveys and sample responses
- **`pnpm db:seed:clear`**: Clear all seeded data and re-seed
- **WARNING**: This will delete existing data in the database.
### Package Level Commands
@@ -92,6 +98,8 @@ Run these commands from the `packages/database` directory:
- Creates new subdirectory with appropriate timestamp
- Generates `migration.ts` file with pre-configured ID and name
- **Note**: Only use Prisma raw queries in data migrations for better performance and to avoid type errors
- **`pnpm db:seed`**: Run the seeding script
- **`pnpm db:seed:clear`**: Clear data and run the seeding script
### Available Scripts
@@ -102,13 +110,41 @@ Run these commands from the `packages/database` directory:
"db:migrate:deploy": "Apply migrations in production",
"db:migrate:dev": "Apply migrations in development",
"db:push": "prisma db push --accept-data-loss",
"db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev",
"db:seed": "Seed the database with sample data",
"db:seed:clear": "Clear all data and re-seed",
"db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev && pnpm db:seed",
"dev": "vite build --watch",
"generate": "prisma generate",
"generate-data-migration": "Create new data migration"
}
```
## Database Seeding
The seeding system provides a quick way to set up a functional environment for development, QA, and testing.
### Safety Guard
To prevent accidental data loss in production, seeding is blocked if `NODE_ENV=production`. If you explicitly need to seed a production-like environment (e.g., staging), you must set:
```bash
ALLOW_SEED=true
```
### Seeding Logic
The `pnpm db:seed` script:
1. **Infrastructure**: Upserts a default organization, project, and environments.
2. **Users**: Creates default users with the following credentials (passwords are hashed):
- **Admin**: `admin@formbricks.com` / `password123`
- **Manager**: `manager@formbricks.com` / `password123`
3. **Surveys**: Creates complex sample surveys (Kitchen Sink, CSAT, Draft, etc.) in the **Production** environment.
4. **Responses**: Generates ~50 realistic responses and displays for each survey.
### Idempotency
By default, the seed script uses `upsert` to ensure it can be run multiple times without creating duplicate infrastructure. To perform a clean reset, use `pnpm db:seed:clear`.
## Migration Workflow
### Adding a Schema Migration
@@ -0,0 +1,9 @@
-- CreateEnum
CREATE TYPE "public"."SurveyScriptMode" AS ENUM ('add', 'replace');
-- AlterTable
ALTER TABLE "public"."Project" ADD COLUMN "customHeadScripts" TEXT;
-- AlterTable
ALTER TABLE "public"."Survey" ADD COLUMN "customHeadScripts" TEXT,
ADD COLUMN "customHeadScriptsMode" "public"."SurveyScriptMode" DEFAULT 'add';
+9 -1
View File
@@ -22,6 +22,9 @@
},
"./zod/*": {
"import": "./zod/*.ts"
},
"./seed/constants": {
"import": "./src/seed/constants.ts"
}
},
"scripts": {
@@ -33,7 +36,9 @@
"db:create-saml-database:deploy": "env SAML_DATABASE_URL=\"${SAML_DATABASE_URL}\" node ./dist/scripts/create-saml-database.js",
"db:create-saml-database:dev": "dotenv -e ../../.env -- node ./dist/scripts/create-saml-database.js",
"db:push": "prisma db push --accept-data-loss",
"db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev",
"db:seed": "dotenv -e ../../.env -- tsx src/seed.ts",
"db:seed:clear": "dotenv -e ../../.env -- tsx src/seed.ts --clear",
"db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev && pnpm db:seed",
"db:start": "pnpm db:setup",
"format": "prisma format",
"generate": "prisma generate",
@@ -45,17 +50,20 @@
"@formbricks/logger": "workspace:*",
"@paralleldrive/cuid2": "2.2.2",
"@prisma/client": "6.14.0",
"bcryptjs": "2.4.3",
"zod": "3.24.4",
"zod-openapi": "4.2.4"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@types/bcryptjs": "2.4.6",
"dotenv-cli": "8.0.0",
"glob": "11.1.0",
"prisma": "6.14.0",
"prisma-json-types-generator": "3.5.4",
"ts-node": "10.9.2",
"tsx": "4.19.2",
"vite": "6.4.1",
"vite-plugin-dts": "4.5.3"
}
+11
View File
@@ -312,6 +312,11 @@ enum displayOptions {
respondMultiple
}
enum SurveyScriptMode {
add
replace
}
/// Represents a complete survey configuration including questions, styling, and display rules.
/// Core model for the survey functionality in Formbricks.
///
@@ -324,6 +329,8 @@ enum displayOptions {
/// @property displayOption - Rules for how often the survey can be shown
/// @property triggers - Actions that can trigger this survey
/// @property attributeFilters - Rules for targeting specific contacts
/// @property customHeadScripts - Survey-specific custom HTML scripts (self-hosted only)
/// @property customHeadScriptsMode - "add" (merge with project) or "replace" (override project)
model Survey {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
@@ -390,6 +397,9 @@ model Survey {
slug String? @unique
customHeadScripts String?
customHeadScriptsMode SurveyScriptMode? @default(add)
@@index([environmentId, updatedAt])
@@index([segmentId])
}
@@ -620,6 +630,7 @@ model Project {
/// [Logo]
logo Json?
projectTeams ProjectTeam[]
customHeadScripts String? // Custom HTML scripts for link surveys (self-hosted only)
@@unique([organizationId, name])
@@index([organizationId])
+596
View File
@@ -0,0 +1,596 @@
import { createId } from "@paralleldrive/cuid2";
import { type Prisma, PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
import { logger } from "@formbricks/logger";
import { SEED_CREDENTIALS, SEED_IDS } from "./seed/constants";
const prisma = new PrismaClient();
const isProduction = process.env.NODE_ENV === "production";
const allowSeed = process.env.ALLOW_SEED === "true";
if (isProduction && !allowSeed) {
logger.error("ERROR: Seeding blocked in production. Set ALLOW_SEED=true to override.");
process.exit(1);
}
const clearData = process.argv.includes("--clear");
// Define local types to avoid resolution issues in seed script
type SurveyElementType =
| "openText"
| "multipleChoiceSingle"
| "multipleChoiceMulti"
| "nps"
| "cta"
| "rating"
| "consent"
| "date"
| "matrix"
| "address"
| "ranking"
| "contactInfo";
interface SurveyQuestion {
id: string;
type: SurveyElementType;
headline: { default: string; [key: string]: string };
subheader?: { default: string; [key: string]: string };
required?: boolean;
placeholder?: { default: string; [key: string]: string };
longAnswer?: boolean;
choices?: { id: string; label: { default: string }; imageUrl?: string }[];
lowerLabel?: { default: string };
upperLabel?: { default: string };
buttonLabel?: { default: string };
buttonUrl?: string;
buttonExternal?: boolean;
dismissButtonLabel?: { default: string };
ctaButtonLabel?: { default: string };
scale?: string;
range?: number;
label?: { default: string };
allowMulti?: boolean;
format?: string;
rows?: { id: string; label: { default: string } }[];
columns?: { id: string; label: { default: string } }[];
addressLine1?: { show: boolean; required: boolean; placeholder: { default: string } };
addressLine2?: { show: boolean; required: boolean; placeholder: { default: string } };
city?: { show: boolean; required: boolean; placeholder: { default: string } };
state?: { show: boolean; required: boolean; placeholder: { default: string } };
zip?: { show: boolean; required: boolean; placeholder: { default: string } };
country?: { show: boolean; required: boolean; placeholder: { default: string } };
firstName?: { show: boolean; required: boolean; placeholder: { default: string } };
lastName?: { show: boolean; required: boolean; placeholder: { default: string } };
email?: { show: boolean; required: boolean; placeholder: { default: string } };
phone?: { show: boolean; required: boolean; placeholder: { default: string } };
company?: { show: boolean; required: boolean; placeholder: { default: string } };
allowMultipleFiles?: boolean;
maxSizeInMB?: number;
}
async function deleteData(): Promise<void> {
logger.info("Clearing existing data...");
const deleteOrder: Prisma.TypeMap["meta"]["modelProps"][] = [
"responseQuotaLink",
"surveyQuota",
"tagsOnResponses",
"tag",
"surveyFollowUp",
"response",
"display",
"surveyTrigger",
"surveyAttributeFilter",
"surveyLanguage",
"survey",
"actionClass",
"contactAttribute",
"contactAttributeKey",
"contact",
"apiKeyEnvironment",
"apiKey",
"segment",
"webhook",
"integration",
"projectTeam",
"teamUser",
"team",
"project",
"invite",
"membership",
"account",
"user",
"organization",
];
for (const model of deleteOrder) {
try {
// @ts-expect-error - prisma[model] is not typed correctly
await prisma[model].deleteMany();
} catch (e: unknown) {
const errorMessage = e instanceof Error ? e.message : String(e);
logger.error(`Could not delete data from ${model}: ${errorMessage}`);
}
}
logger.info("Data cleared.");
}
const KITCHEN_SINK_QUESTIONS: SurveyQuestion[] = [
{
id: createId(),
type: "openText",
headline: { default: "What do you think of Formbricks?" },
subheader: { default: "Please be honest!" },
required: true,
placeholder: { default: "Your feedback here..." },
longAnswer: true,
},
{
id: createId(),
type: "multipleChoiceSingle",
headline: { default: "How often do you use Formbricks?" },
required: true,
choices: [
{ id: createId(), label: { default: "Daily" } },
{ id: createId(), label: { default: "Weekly" } },
{ id: createId(), label: { default: "Monthly" } },
{ id: createId(), label: { default: "Rarely" } },
],
},
{
id: createId(),
type: "multipleChoiceMulti",
headline: { default: "Which features do you use?" },
required: false,
choices: [
{ id: createId(), label: { default: "Surveys" } },
{ id: createId(), label: { default: "Analytics" } },
{ id: createId(), label: { default: "Integrations" } },
{ id: createId(), label: { default: "Action Tracking" } },
],
},
{
id: createId(),
type: "nps",
headline: { default: "How likely are you to recommend Formbricks?" },
required: true,
lowerLabel: { default: "Not likely" },
upperLabel: { default: "Very likely" },
},
{
id: createId(),
type: "cta",
headline: { default: "Check out our documentation!" },
required: true,
ctaButtonLabel: { default: "Go to Docs" },
buttonUrl: "https://formbricks.com/docs",
buttonExternal: true,
},
{
id: createId(),
type: "rating",
headline: { default: "Rate your overall experience" },
required: true,
scale: "star",
range: 5,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
},
{
id: createId(),
type: "consent",
headline: { default: "Do you agree to our terms?" },
required: true,
label: { default: "I agree to the terms and conditions" },
},
{
id: createId(),
type: "date",
headline: { default: "When did you start using Formbricks?" },
required: true,
format: "M-d-y",
},
{
id: createId(),
type: "matrix",
headline: { default: "How do you feel about these aspects?" },
required: true,
rows: [
{ id: createId(), label: { default: "UI Design" } },
{ id: createId(), label: { default: "Performance" } },
{ id: createId(), label: { default: "Documentation" } },
],
columns: [
{ id: createId(), label: { default: "Disappointed" } },
{ id: createId(), label: { default: "Neutral" } },
{ id: createId(), label: { default: "Satisfied" } },
],
},
{
id: createId(),
type: "address",
headline: { default: "Where are you located?" },
required: true,
addressLine1: { show: true, required: true, placeholder: { default: "Address Line 1" } },
addressLine2: { show: true, required: false, placeholder: { default: "Address Line 2" } },
city: { show: true, required: true, placeholder: { default: "City" } },
state: { show: true, required: true, placeholder: { default: "State" } },
zip: { show: true, required: true, placeholder: { default: "Zip" } },
country: { show: true, required: true, placeholder: { default: "Country" } },
},
{
id: createId(),
type: "ranking",
headline: { default: "Rank these features" },
required: true,
choices: [
{ id: createId(), label: { default: "Feature A" } },
{ id: createId(), label: { default: "Feature B" } },
{ id: createId(), label: { default: "Feature C" } },
],
},
{
id: createId(),
type: "contactInfo",
headline: { default: "How can we reach you?" },
required: true,
firstName: { show: true, required: true, placeholder: { default: "First Name" } },
lastName: { show: true, required: true, placeholder: { default: "Last Name" } },
email: { show: true, required: true, placeholder: { default: "Email" } },
phone: { show: true, required: false, placeholder: { default: "Phone" } },
company: { show: true, required: false, placeholder: { default: "Company" } },
},
];
interface SurveyBlock {
id: string;
name: string;
elements: SurveyQuestion[];
}
type ResponseValue = string | number | string[] | Record<string, string>;
const generateQuestionResponse = (q: SurveyQuestion, index: number): ResponseValue | undefined => {
const responseGenerators: Record<SurveyElementType, () => ResponseValue | undefined> = {
openText: () => `Sample response ${String(index)}`,
multipleChoiceSingle: () =>
q.choices ? q.choices[Math.floor(Math.random() * q.choices.length)].label.default : undefined,
multipleChoiceMulti: () =>
q.choices ? [q.choices[0].label.default, q.choices[1].label.default] : undefined,
nps: () => Math.floor(Math.random() * 11),
rating: () => (q.range ? Math.floor(Math.random() * q.range) + 1 : undefined),
cta: () => "clicked",
consent: () => "accepted",
date: () => new Date().toISOString().split("T")[0],
matrix: () => {
const matrixData: Record<string, string> = {};
if (q.rows && q.columns) {
for (const row of q.rows) {
matrixData[row.label.default] =
q.columns[Math.floor(Math.random() * q.columns.length)].label.default;
}
}
return matrixData;
},
ranking: () =>
q.choices ? q.choices.map((c) => c.label.default).sort(() => Math.random() - 0.5) : undefined,
address: () => ({
addressLine1: "Main St 1",
city: "Berlin",
state: "Berlin",
zip: "10115",
country: "Germany",
}),
contactInfo: () => ({
firstName: "John",
lastName: "Doe",
email: `john.doe.${String(index)}@example.com`,
}),
};
return responseGenerators[q.type]();
};
async function generateResponses(surveyId: string, count: number): Promise<void> {
logger.info(`Generating ${String(count)} responses for survey ${surveyId}...`);
const survey = await prisma.survey.findUnique({
where: { id: surveyId },
});
if (!survey) return;
const blocks = survey.blocks as unknown as SurveyBlock[];
const questions = blocks.flatMap((block) => block.elements);
for (let i = 0; i < count; i++) {
const data: Record<string, ResponseValue> = {};
for (const q of questions) {
const response = generateQuestionResponse(q, i);
if (response !== undefined) {
data[q.id] = response;
}
}
await prisma.$transaction(async (tx) => {
const display = await tx.display.create({
data: {
surveyId,
},
});
await tx.response.create({
data: {
surveyId,
finished: true,
// @ts-expect-error - data is not typed correctly
data: data as unknown as Prisma.InputJsonValue,
displayId: display.id,
},
});
});
}
// Generate some displays without responses (e.g., 30% more)
const extraDisplays = Math.floor(count * 0.3);
logger.info(`Generating ${String(extraDisplays)} extra displays for survey ${surveyId}...`);
for (let i = 0; i < extraDisplays; i++) {
await prisma.display.create({
data: {
surveyId,
},
});
}
}
async function main(): Promise<void> {
if (clearData) {
await deleteData();
}
logger.info("Seeding base infrastructure...");
// Organization
const organization = await prisma.organization.upsert({
where: { id: SEED_IDS.ORGANIZATION },
update: {},
create: {
id: SEED_IDS.ORGANIZATION,
name: "Seed Organization",
billing: {
plan: "free",
limits: { projects: 3, monthly: { responses: 1500, miu: 2000 } },
stripeCustomerId: null,
periodStart: new Date(),
period: "monthly",
},
},
});
// Users
const passwordHash = await bcrypt.hash(SEED_CREDENTIALS.ADMIN.password, 10);
await prisma.user.upsert({
where: { id: SEED_IDS.USER_ADMIN },
update: {},
create: {
id: SEED_IDS.USER_ADMIN,
name: "Admin User",
email: SEED_CREDENTIALS.ADMIN.email,
password: passwordHash,
emailVerified: new Date(),
memberships: {
create: {
organizationId: organization.id,
role: "owner",
accepted: true,
},
},
},
});
await prisma.user.upsert({
where: { id: SEED_IDS.USER_MANAGER },
update: {},
create: {
id: SEED_IDS.USER_MANAGER,
name: "Manager User",
email: SEED_CREDENTIALS.MANAGER.email,
password: passwordHash,
emailVerified: new Date(),
memberships: {
create: {
organizationId: organization.id,
role: "manager",
accepted: true,
},
},
},
});
await prisma.user.upsert({
where: { id: SEED_IDS.USER_MEMBER },
update: {},
create: {
id: SEED_IDS.USER_MEMBER,
name: "Member User",
email: SEED_CREDENTIALS.MEMBER.email,
password: passwordHash,
emailVerified: new Date(),
memberships: {
create: {
organizationId: organization.id,
role: "member",
accepted: true,
},
},
},
});
// Project
const project = await prisma.project.upsert({
where: { id: SEED_IDS.PROJECT },
update: {},
create: {
id: SEED_IDS.PROJECT,
name: "Seed Project",
organizationId: organization.id,
},
});
// Environments
await prisma.environment.upsert({
where: { id: SEED_IDS.ENV_DEV },
update: { appSetupCompleted: false },
create: {
id: SEED_IDS.ENV_DEV,
type: "development",
projectId: project.id,
appSetupCompleted: false,
attributeKeys: {
create: [
{ name: "Email", key: "email", isUnique: true, type: "default" },
{ name: "First Name", key: "firstName", isUnique: false, type: "default" },
{ name: "Last Name", key: "lastName", isUnique: false, type: "default" },
{ name: "userId", key: "userId", isUnique: true, type: "default" },
],
},
},
});
const prodEnv = await prisma.environment.upsert({
where: { id: SEED_IDS.ENV_PROD },
update: { appSetupCompleted: false },
create: {
id: SEED_IDS.ENV_PROD,
type: "production",
projectId: project.id,
appSetupCompleted: false,
attributeKeys: {
create: [
{ name: "Email", key: "email", isUnique: true, type: "default" },
{ name: "First Name", key: "firstName", isUnique: false, type: "default" },
{ name: "Last Name", key: "lastName", isUnique: false, type: "default" },
{ name: "userId", key: "userId", isUnique: true, type: "default" },
],
},
},
});
logger.info("Seeding surveys...");
const createSurveyWithBlocks = async (
id: string,
name: string,
environmentId: string,
status: "inProgress" | "draft" | "completed",
questions: SurveyQuestion[]
): Promise<void> => {
const blocks = [
{
id: createId(),
name: "Main Block",
elements: questions,
},
];
await prisma.survey.upsert({
where: { id },
update: {
environmentId,
type: "link",
// @ts-expect-error - blocks is not typed correctly
blocks: blocks as unknown as Prisma.InputJsonValue[],
},
create: {
id,
name,
environmentId,
status,
type: "link",
// @ts-expect-error - blocks is not typed correctly
blocks: blocks as unknown as Prisma.InputJsonValue[],
},
});
};
// Kitchen Sink Survey
await createSurveyWithBlocks(
SEED_IDS.SURVEY_KITCHEN_SINK,
"Kitchen Sink Survey",
prodEnv.id,
"inProgress",
KITCHEN_SINK_QUESTIONS
);
// CSAT Survey
await createSurveyWithBlocks(SEED_IDS.SURVEY_CSAT, "CSAT Survey", prodEnv.id, "inProgress", [
{
id: createId(),
type: "rating",
headline: { default: "How satisfied are you with our product?" },
required: true,
scale: "smiley",
range: 5,
},
]);
// Draft Survey
await createSurveyWithBlocks(SEED_IDS.SURVEY_DRAFT, "Draft Survey", prodEnv.id, "draft", [
{
id: createId(),
type: "openText",
headline: { default: "Coming soon..." },
required: false,
},
]);
// Completed Survey
await createSurveyWithBlocks(SEED_IDS.SURVEY_COMPLETED, "Exit Survey", prodEnv.id, "completed", [
{
id: createId(),
type: "multipleChoiceSingle",
headline: { default: "Why are you leaving?" },
required: true,
choices: [
{ id: createId(), label: { default: "Too expensive" } },
{ id: createId(), label: { default: "Found a better alternative" } },
{ id: createId(), label: { default: "Missing features" } },
],
},
]);
logger.info("Generating responses...");
await generateResponses(SEED_IDS.SURVEY_KITCHEN_SINK, 50);
await generateResponses(SEED_IDS.SURVEY_CSAT, 50);
await generateResponses(SEED_IDS.SURVEY_COMPLETED, 50);
logger.info(`\n${"=".repeat(50)}`);
logger.info("🚀 SEEDING COMPLETED SUCCESSFULLY");
logger.info("=".repeat(50));
logger.info("\nLog in with the following credentials:");
logger.info(`\n Admin (Owner):`);
logger.info(` Email: ${SEED_CREDENTIALS.ADMIN.email}`);
logger.info(` Password: (see SEED_CREDENTIALS configuration in constants.ts)`);
logger.info(`\n Manager:`);
logger.info(` Email: ${SEED_CREDENTIALS.MANAGER.email}`);
logger.info(` Password: (see SEED_CREDENTIALS configuration in constants.ts)`);
logger.info(`\n Member:`);
logger.info(` Email: ${SEED_CREDENTIALS.MEMBER.email}`);
logger.info(` Password: (see SEED_CREDENTIALS configuration in constants.ts)`);
logger.info(`\n${"=".repeat(50)}\n`);
}
main()
.catch((e: unknown) => {
logger.error(e);
process.exit(1);
})
.finally(() => {
prisma.$disconnect().catch((e: unknown) => {
logger.error(e, "Error disconnecting prisma");
});
});
+19
View File
@@ -0,0 +1,19 @@
export const SEED_IDS = {
USER_ADMIN: "clseedadmin000000000000",
USER_MANAGER: "clseedmanager0000000000",
USER_MEMBER: "clseedmember00000000000",
ORGANIZATION: "clseedorg0000000000000",
PROJECT: "clseedproject000000000",
ENV_DEV: "clseedenvdev0000000000",
ENV_PROD: "clseedenvprod000000000",
SURVEY_KITCHEN_SINK: "clseedsurveykitchen00",
SURVEY_CSAT: "clseedsurveycsat000000",
SURVEY_DRAFT: "clseedsurveydraft00000",
SURVEY_COMPLETED: "clseedsurveycomplete00",
} as const;
export const SEED_CREDENTIALS = {
ADMIN: { email: "admin@formbricks.com", password: "Password#123" },
MANAGER: { email: "manager@formbricks.com", password: "Password#123" },
MEMBER: { email: "member@formbricks.com", password: "Password#123" },
} as const;
@@ -172,7 +172,7 @@ function DateElement({
onSelect={handleDateSelect}
locale={dateLocale}
required={required}
className="rounded-input border-input-border bg-input-bg text-input-text shadow-input mx-auto w-full max-w-[25rem] border"
className="rounded-input border-input-border bg-input-bg text-input-text shadow-input mx-auto h-[stretch] w-full max-w-[25rem] border"
/>
</div>
</div>
+3 -4
View File
@@ -9,7 +9,6 @@ checksums:
common/company_logo: 82d5c0d5994508210ee02d684819f4b8
common/finish: ffa7a10f71182b48fefed7135bee24fa
common/language_switch: fd72a9ada13f672f4fd5da863b22cc46
common/less_than_x_minutes: 8a8528651d0b60dc93be451abf6a139b
common/next: 89ddbcf710eba274963494f312bdc8a9
common/open_in_new_tab: 6844e4922a7a40a7ee25c10ea109cdeb
common/people_responded: b685fb877090d8658db724ad07a0dbd8
@@ -25,12 +24,12 @@ checksums:
common/retry: 6e44d18639560596569a1278f9c83676
common/retrying: 0cb623dbdcbf16d3680f0180ceac734c
common/sending_responses: 184772f70cca69424eaf34f73520789f
common/takes: 01f96e2e84741ea8392d97ff4bd2aa52
common/takes_less_than_x_minutes: 1208ce0d4c0a679c11c7bd209b6ccc47
common/takes_x_minutes: 001d12366d07b406f50669e761d63e69
common/takes_x_plus_minutes: 145b8f287de140e98f492c8db2f9fa0b
common/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
common/the_servers_cannot_be_reached_at_the_moment: f8adbeccac69f9230a55b5b3af52b081
common/they_will_be_redirected_immediately: 936bc99cb575cba95ea8f04d82bb353b
common/x_minutes: bf6ec8800c29b1447226447a991b9510
common/x_plus_minutes: 2ef597aa029e3c71d442455fbb751991
common/your_feedback_is_stuck: db2b6aba26723b01aee0fc918d3ca052
errors/file_input/duplicate_files: 198dd29e67beb6abc5b2534ede7d7f68
errors/file_input/file_size_exceeded: 072045b042a39fa1df76200f8fa36dd4
+3 -4
View File
@@ -8,7 +8,6 @@
"company_logo": "شعار الشركة",
"finish": "إنهاء",
"language_switch": "تبديل اللغة",
"less_than_x_minutes": "{count, plural, one {أقل من دقيقة واحدة} two {أقل من دقيقتين} few {أقل من {count} دقائق} many {أقل من {count} دقيقة} other {أقل من {count} دقيقة}}",
"next": "التالي",
"open_in_new_tab": "فتح في علامة تبويب جديدة",
"people_responded": "{count, plural, one {شخص واحد استجاب} two {شخصان استجابا} few {{count} أشخاص استجابوا} many {{count} شخصًا استجابوا} other {{count} شخص استجابوا}}",
@@ -24,12 +23,12 @@
"retry": "إعادة المحاولة",
"retrying": "إعادة المحاولة...",
"sending_responses": "جارٍ إرسال الردود...",
"takes": "يأخذ",
"takes_less_than_x_minutes": "{count, plural, zero {يستغرق أقل من دقيقة} one {يستغرق أقل من دقيقة واحدة} two {يستغرق أقل من دقيقتين} few {يستغرق أقل من {count} دقائق} many {يستغرق أقل من {count} دقيقة} other {يستغرق أقل من {count} دقيقة}}",
"takes_x_minutes": "{count, plural, zero {يستغرق صفر دقائق} one {يستغرق دقيقة واحدة} two {يستغرق دقيقتين} few {يستغرق {count} دقائق} many {يستغرق {count} دقيقة} other {يستغرق {count} دقيقة}}",
"takes_x_plus_minutes": "يستغرق {count}+ دقيقة",
"terms_of_service": "شروط الخدمة",
"the_servers_cannot_be_reached_at_the_moment": "لا يمكن الوصول إلى الخوادم في الوقت الحالي.",
"they_will_be_redirected_immediately": "سيتم إعادة توجيههم فورًا",
"x_minutes": "{count, plural, one {دقيقة واحدة} two {دقيقتان} few {{count} دقائق} many {{count} دقيقة} other {{count} دقيقة}}",
"x_plus_minutes": "{count}+ دقيقة",
"your_feedback_is_stuck": "تعليقك عالق :("
},
"errors": {
+3 -4
View File
@@ -8,7 +8,6 @@
"company_logo": "Firmenlogo",
"finish": "Fertig",
"language_switch": "Sprachwechsel",
"less_than_x_minutes": "{count, plural, one {weniger als 1 Minute} other {weniger als {count} Minuten}}",
"next": "Weiter",
"open_in_new_tab": "In neuem Tab öffnen",
"people_responded": "{count, plural, one {1 Person hat geantwortet} other {{count} Personen haben geantwortet}}",
@@ -24,12 +23,12 @@
"retry": "Wiederholen",
"retrying": "Erneuter Versuch...",
"sending_responses": "Antworten werden gesendet...",
"takes": "Dauert",
"takes_less_than_x_minutes": "{count, plural, one {Dauert weniger als 1 Minute} other {Dauert weniger als {count} Minuten}}",
"takes_x_minutes": "{count, plural, one {Dauert 1 Minute} other {Dauert {count} Minuten}}",
"takes_x_plus_minutes": "Dauert {count}+ Minuten",
"terms_of_service": "Nutzungsbedingungen",
"the_servers_cannot_be_reached_at_the_moment": "Die Server sind momentan nicht erreichbar.",
"they_will_be_redirected_immediately": "Sie werden sofort weitergeleitet",
"x_minutes": "{count, plural, one {1 Minute} other {{count} Minuten}}",
"x_plus_minutes": "{count}+ Minuten",
"your_feedback_is_stuck": "Ihr Feedback steckt fest :("
},
"errors": {
+3 -4
View File
@@ -8,7 +8,6 @@
"company_logo": "Company Logo",
"finish": "Finish",
"language_switch": "Language switch",
"less_than_x_minutes": "{count, plural, one {less than 1 minute} other {less than {count} minutes}}",
"next": "Next",
"open_in_new_tab": "Open in new tab",
"people_responded": "{count, plural, one {1 person responded} other {{count} people responded}}",
@@ -24,12 +23,12 @@
"retry": "Retry",
"retrying": "Retrying...",
"sending_responses": "Sending responses...",
"takes": "Takes",
"takes_less_than_x_minutes": "{count, plural, one {Takes less than 1 minute} other {Takes less than {count} minutes}}",
"takes_x_minutes": "{count, plural, one {Takes 1 minute} other {Takes {count} minutes}}",
"takes_x_plus_minutes": "Takes {count}+ minutes",
"terms_of_service": "Terms of Service",
"the_servers_cannot_be_reached_at_the_moment": "The servers cannot be reached at the moment.",
"they_will_be_redirected_immediately": "They will be redirected immediately",
"x_minutes": "{count, plural, one {1 minute} other {{count} minutes}}",
"x_plus_minutes": "{count}+ minutes",
"your_feedback_is_stuck": "Your feedback is stuck :("
},
"errors": {
+3 -4
View File
@@ -8,7 +8,6 @@
"company_logo": "Logo de la empresa",
"finish": "Finalizar",
"language_switch": "Cambio de idioma",
"less_than_x_minutes": "{count, plural, one {menos de 1 minuto} other {menos de {count} minutos}}",
"next": "Siguiente",
"open_in_new_tab": "Abrir en nueva pestaña",
"people_responded": "{count, plural, one {1 persona respondió} other {{count} personas respondieron}}",
@@ -24,12 +23,12 @@
"retry": "Reintentar",
"retrying": "Reintentando...",
"sending_responses": "Enviando respuestas...",
"takes": "Tomas",
"takes_less_than_x_minutes": "{count, plural, one {Toma menos de 1 minuto} other {Toma menos de {count} minutos}}",
"takes_x_minutes": "{count, plural, one {Toma 1 minuto} other {Toma {count} minutos}}",
"takes_x_plus_minutes": "Toma {count}+ minutos",
"terms_of_service": "Términos de servicio",
"the_servers_cannot_be_reached_at_the_moment": "Los servidores no pueden ser alcanzados en este momento.",
"they_will_be_redirected_immediately": "Serán redirigidos inmediatamente",
"x_minutes": "{count, plural, one {1 minuto} other {{count} minutos}}",
"x_plus_minutes": "{count}+ minutos",
"your_feedback_is_stuck": "Tu feedback está atascado :("
},
"errors": {
+3 -4
View File
@@ -8,7 +8,6 @@
"company_logo": "Logo de l'entreprise",
"finish": "Terminer",
"language_switch": "Changement de langue",
"less_than_x_minutes": "{count, plural, one {moins d'une minute} other {moins de {count} minutes}}",
"next": "Suivant",
"open_in_new_tab": "Ouvrir dans un nouvel onglet",
"people_responded": "{count, plural, one {1 personne a répondu} other {{count} personnes ont répondu}}",
@@ -24,12 +23,12 @@
"retry": "Réessayer",
"retrying": "Nouvelle tentative...",
"sending_responses": "Envoi des réponses...",
"takes": "Prises",
"takes_less_than_x_minutes": "{count, plural, one {Prend moins d'une minute} other {Prend moins de {count} minutes}}",
"takes_x_minutes": "{count, plural, one {Prend 1 minute} other {Prend {count} minutes}}",
"takes_x_plus_minutes": "Prend {count}+ minutes",
"terms_of_service": "Conditions d'utilisation",
"the_servers_cannot_be_reached_at_the_moment": "Les serveurs ne sont pas accessibles pour le moment.",
"they_will_be_redirected_immediately": "Ils seront redirigés immédiatement",
"x_minutes": "{count, plural, one {1 minute} other {{count} minutes}}",
"x_plus_minutes": "{count}+ minutes",
"your_feedback_is_stuck": "Votre feedback est bloqué :("
},
"errors": {

Some files were not shown because too many files have changed in this diff Show More