Compare commits

...

27 Commits

Author SHA1 Message Date
Dhruwang d94fbc05f4 fix test 2026-01-09 16:48:52 +05:30
Dhruwang 541ddc0c4d updated lock files 2026-01-09 14:06:45 +05:30
Dhruwang 6d67fc288a Merge branch 'main' of https://github.com/formbricks/formbricks into feature-formal-wording 2026-01-09 14:04:10 +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
Balázs Úr dffddd0bce resolve conflicts 2026-01-07 09:16:42 +01:00
Balázs Úr 4fca961cd5 resolve conflicts 2026-01-07 09:14:37 +01: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
Dhruwang c22a55429d Merge branch 'main' of https://github.com/formbricks/formbricks into feature-formal-wording 2026-01-06 18:08:15 +05:30
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
Balázs Úr dfc86e7dad resolve conflicts 2025-12-31 11:23:48 +01:00
Balázs Úr 3b520b5855 resolve conflicts 2025-12-31 11:19:19 +01:00
Balázs Úr 4326772989 chore: use Unicode punctuation, remove contractions 2025-12-30 19:57:17 +01:00
Balázs Úr d209e411cb chore: use Unicode punctuation, remove contractions, make wording consistent 2025-12-30 11:55:01 +01:00
99 changed files with 3547 additions and 1060 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: [
{
+238 -217
View File
File diff suppressed because it is too large Load Diff
+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;
+23
View File
@@ -1690,6 +1690,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 +1945,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.",
File diff suppressed because it is too large Load Diff
+23
View File
@@ -1690,6 +1690,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 +1945,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.",
+23
View File
@@ -1690,6 +1690,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 +1945,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.",
+23
View File
@@ -1690,6 +1690,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 +1945,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}をすべてのフォーム、回答、人物、アクション、属性を含めて削除します。",
+23
View File
@@ -1690,6 +1690,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 +1945,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.",
+23
View File
@@ -1690,6 +1690,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 +1945,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.",
+23
View File
@@ -1690,6 +1690,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 +1945,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.",
+23
View File
@@ -1690,6 +1690,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 +1945,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.",
+23
View File
@@ -1690,6 +1690,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 +1945,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} вместе со всеми опросами, ответами, пользователями, действиями и атрибутами.",
+23
View File
@@ -1690,6 +1690,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 +1945,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.",
+23
View File
@@ -1690,6 +1690,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 +1945,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},包括所有调查、回应、人员、动作和属性。",
+23
View File
@@ -1690,6 +1690,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 +1945,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)}
/>
@@ -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 })}
@@ -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;
+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",
+1 -1
View File
@@ -35,7 +35,7 @@ test.describe("Onboarding Flow Test", async () => {
await page.getByPlaceholder("e.g. Formbricks").fill(projectName);
await page.locator("#form-next-button").click();
await page.getByRole("button", { name: "I'll do it later" }).click();
await page.getByRole("button", { name: "I will do it later" }).click();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
await expect(page.getByText(projectName)).toBeVisible();
+1 -1
View File
@@ -121,7 +121,7 @@ export const finishOnboarding = async (
await page.locator("#form-next-button").click();
if (projectChannel !== "link") {
await page.getByRole("button", { name: "I'll do it later" }).click();
await page.getByRole("button", { name: "I will do it later" }).click();
}
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
+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>
+7 -8
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
@@ -23,14 +22,14 @@ checksums:
common/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
common/respondents_will_not_see_this_card: 18c3dd44d6ff6ca2310ad196b84f30d3
common/retry: 6e44d18639560596569a1278f9c83676
common/retrying: 0cb623dbdcbf16d3680f0180ceac734c
common/sending_responses: 184772f70cca69424eaf34f73520789f
common/takes: 01f96e2e84741ea8392d97ff4bd2aa52
common/retrying: 40989361ea5f6b95897b95ac928b5bd9
common/sending_responses: 244f1aebc3f6a101ae2f8b630d7967ec
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
@@ -40,7 +39,7 @@ checksums:
errors/file_input/upload_failed: 735fdfc1a37ab035121328237ddd6fd0
errors/file_input/you_can_only_upload_a_maximum_of_files: 72fe144f81075e5b06bae53b3a84d4db
errors/invalid_device_error/message: 8813dcd0e3e41934af18d7a15f8c83f4
errors/invalid_device_error/title: 20d261b478aaba161b0853a588926e23
errors/invalid_device_error/title: ea7dbb9970c717e4d466f8e1211bd461
errors/please_book_an_appointment: 9e8acea3721f660b6a988f79c4105ab8
errors/please_enter_a_valid_email_address: 8de4bc8832b11b380bc4cbcedc16e48b
errors/please_enter_a_valid_phone_number: 1530eb9ab7d6d190bddb37667c711631
@@ -51,4 +50,4 @@ checksums:
errors/please_select_an_option: 9fede3bb9ded29301e89b98616e3583a
errors/please_upload_a_file: 4356dfca88553acb377664c923c2d6b7
errors/recaptcha_error/message: b3f2c5950cbc0887f391f9e2bccb676e
errors/recaptcha_error/title: 8e923ec38a92041569879a39c6467131
errors/recaptcha_error/title: eb8f1106e0b4cb6756c5a76fd9400e67
+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": {
+7 -8
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}}",
@@ -22,14 +21,14 @@
"required": "Required",
"respondents_will_not_see_this_card": "Respondents will not see this card",
"retry": "Retry",
"retrying": "Retrying...",
"sending_responses": "Sending responses...",
"takes": "Takes",
"retrying": "Retrying",
"sending_responses": "Sending responses",
"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": {
@@ -44,7 +43,7 @@
},
"invalid_device_error": {
"message": "Please disable spam protection in the survey settings to continue using this device.",
"title": "This device doesnt support spam protection."
"title": "This device does not support spam protection."
},
"please_book_an_appointment": "Please book an appointment",
"please_enter_a_valid_email_address": "Please enter a valid email address",
@@ -57,7 +56,7 @@
"please_upload_a_file": "Please upload a file",
"recaptcha_error": {
"message": "Your response could not be submitted because it was flagged as automated activity. If you breathe, please try again.",
"title": "We couldn't verify that you're human."
"title": "We could not verify that you are human."
}
}
}
+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": {
+3 -4
View File
@@ -8,7 +8,6 @@
"company_logo": "कंपनी लोगो",
"finish": "समाप्त करें",
"language_switch": "भाषा बदलें",
"less_than_x_minutes": "{count, plural, one {1 मिनट से कम} other {{count} मिनट से कम}}",
"next": "अगला",
"open_in_new_tab": "नए टैब में खोलें",
"people_responded": "{count, plural, one {1 व्यक्ति ने जवाब दिया} other {{count} लोगों ने जवाब दिया}}",
@@ -24,12 +23,12 @@
"retry": "पुनः प्रयास करें",
"retrying": "पुनः प्रयास कर रहे हैं...",
"sending_responses": "प्रतिक्रियाएँ भेज रहे हैं...",
"takes": "लेता है",
"takes_less_than_x_minutes": "{count, plural, one {1 मिनट से कम लगता है} other {{count} मिनट से कम लगता है}}",
"takes_x_minutes": "{count, plural, one {1 मिनट लगता है} 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 {1 मिनट} other {{count} मिनट}}",
"x_plus_minutes": "{count}+ मिनट",
"your_feedback_is_stuck": "आपकी प्रतिक्रिया अटक गई है :("
},
"errors": {
+3 -4
View File
@@ -8,7 +8,6 @@
"company_logo": "Logo aziendale",
"finish": "Fine",
"language_switch": "Cambio lingua",
"less_than_x_minutes": "{count, plural, one {meno di 1 minuto} other {meno di {count} minuti}}",
"next": "Avanti",
"open_in_new_tab": "Apri in una nuova scheda",
"people_responded": "{count, plural, one {1 persona ha risposto} other {{count} persone hanno risposto}}",
@@ -24,12 +23,12 @@
"retry": "Riprova",
"retrying": "Riprovando...",
"sending_responses": "Invio risposte in corso...",
"takes": "Riprese",
"takes_less_than_x_minutes": "{count, plural, one {Richiede meno di 1 minuto} other {Richiede meno di {count} minuti}}",
"takes_x_minutes": "{count, plural, one {Richiede 1 minuto} other {Richiede {count} minuti}}",
"takes_x_plus_minutes": "Richiede più di {count} minuti",
"terms_of_service": "Termini di servizio",
"the_servers_cannot_be_reached_at_the_moment": "I server non sono raggiungibili al momento.",
"they_will_be_redirected_immediately": "Saranno reindirizzati immediatamente",
"x_minutes": "{count, plural, one {1 minuto} other {{count} minuti}}",
"x_plus_minutes": "{count}+ minuti",
"your_feedback_is_stuck": "Il tuo feedback è bloccato :("
},
"errors": {
+3 -4
View File
@@ -8,7 +8,6 @@
"company_logo": "会社ロゴ",
"finish": "完了",
"language_switch": "言語切替",
"less_than_x_minutes": "{count, plural, other {{count}分未満}}",
"next": "次へ",
"open_in_new_tab": "新しいタブで開く",
"people_responded": "{count, plural, other {{count}人が回答しました}}",
@@ -24,12 +23,12 @@
"retry": "再試行",
"retrying": "再試行中...",
"sending_responses": "回答を送信中...",
"takes": "所要時間",
"takes_less_than_x_minutes": "{count, plural, one {1分未満} other {{count}分未満}}",
"takes_x_minutes": "{count, plural, one {1分} 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 {1分} other {{count}分}}",
"x_plus_minutes": "{count}分以上",
"your_feedback_is_stuck": "フィードバックが送信できません :("
},
"errors": {
+3 -4
View File
@@ -8,7 +8,6 @@
"company_logo": "Bedrijfslogo",
"finish": "Voltooien",
"language_switch": "Taalschakelaar",
"less_than_x_minutes": "{count, plural, one {minder dan 1 minuut} other {minder dan {count} minuten}}",
"next": "Volgende",
"open_in_new_tab": "Openen in nieuw tabblad",
"people_responded": "{count, plural, one {1 persoon heeft gereageerd} other {{count} mensen hebben gereageerd}}",
@@ -24,12 +23,12 @@
"retry": "Opnieuw proberen",
"retrying": "Opnieuw proberen...",
"sending_responses": "Reacties verzenden...",
"takes": "Neemt",
"takes_less_than_x_minutes": "{count, plural, one {Duurt minder dan 1 minuut} other {Duurt minder dan {count} minuten}}",
"takes_x_minutes": "{count, plural, one {Duurt 1 minuut} other {Duurt {count} minuten}}",
"takes_x_plus_minutes": "Duurt {count}+ minuten",
"terms_of_service": "Servicevoorwaarden",
"the_servers_cannot_be_reached_at_the_moment": "De servers zijn momenteel niet bereikbaar.",
"they_will_be_redirected_immediately": "Ze worden onmiddellijk doorgestuurd",
"x_minutes": "{count, plural, one {1 minuut} other {{count} minuten}}",
"x_plus_minutes": "{count}+ minuten",
"your_feedback_is_stuck": "Je feedback blijft hangen :("
},
"errors": {
+3 -4
View File
@@ -8,7 +8,6 @@
"company_logo": "Logo da empresa",
"finish": "Finalizar",
"language_switch": "Alternar idioma",
"less_than_x_minutes": "{count, plural, one {menos de 1 minuto} other {menos de {count} minutos}}",
"next": "Próximo",
"open_in_new_tab": "Abrir em nova aba",
"people_responded": "{count, plural, one {1 pessoa respondeu} other {{count} pessoas responderam}}",
@@ -24,12 +23,12 @@
"retry": "Tentar novamente",
"retrying": "Tentando novamente...",
"sending_responses": "Enviando respostas...",
"takes": "Leva",
"takes_less_than_x_minutes": "{count, plural, one {Leva menos de 1 minuto} other {Leva menos de {count} minutos}}",
"takes_x_minutes": "{count, plural, one {Leva 1 minuto} other {Leva {count} minutos}}",
"takes_x_plus_minutes": "Leva {count}+ minutos",
"terms_of_service": "Termos de serviço",
"the_servers_cannot_be_reached_at_the_moment": "Os servidores não podem ser alcançados no momento.",
"they_will_be_redirected_immediately": "Eles serão redirecionados imediatamente",
"x_minutes": "{count, plural, one {1 minuto} other {{count} minutos}}",
"x_plus_minutes": "{count}+ minutos",
"your_feedback_is_stuck": "Seu feedback está preso :("
},
"errors": {
+3 -4
View File
@@ -8,7 +8,6 @@
"company_logo": "Sigla companiei",
"finish": "Finalizează",
"language_switch": "Schimbare limbă",
"less_than_x_minutes": "{count, plural, one {mai puțin de 1 minut} other {mai puțin de {count} minute}}",
"next": "Următorul",
"open_in_new_tab": "Deschide într-o filă nouă",
"people_responded": "{count, plural, one {1 persoană a răspuns} other {{count} persoane au răspuns}}",
@@ -24,12 +23,12 @@
"retry": "Reîncearcă",
"retrying": "Se reîncearcă...",
"sending_responses": "Trimiterea răspunsurilor...",
"takes": "Durează",
"takes_less_than_x_minutes": "{count, plural, one {Durează mai puțin de 1 minut} few {Durează mai puțin de {count} minute} other {Durează mai puțin de {count} de minute}}",
"takes_x_minutes": "{count, plural, one {Durează 1 minut} few {Durează {count} minute} other {Durează {count} de minute}}",
"takes_x_plus_minutes": "Durează peste {count} minute",
"terms_of_service": "Termeni și condiții",
"the_servers_cannot_be_reached_at_the_moment": "Serverele nu pot fi accesate momentan.",
"they_will_be_redirected_immediately": "Vor fi redirecționați imediat",
"x_minutes": "{count, plural, one {1 minut} other {{count} minute}}",
"x_plus_minutes": "{count}+ minute",
"your_feedback_is_stuck": "Feedback-ul tău este blocat :("
},
"errors": {
+3 -4
View File
@@ -8,7 +8,6 @@
"company_logo": "Логотип компании",
"finish": "Завершить",
"language_switch": "Переключение языка",
"less_than_x_minutes": "{count, plural, one {менее 1 минуты} other {менее {count} минут}}",
"next": "Далее",
"open_in_new_tab": "Открыть в новой вкладке",
"people_responded": "{count, plural, one {1 человек ответил} other {{count} человека ответили}}",
@@ -24,12 +23,12 @@
"retry": "Повторить",
"retrying": "Повторная попытка...",
"sending_responses": "Отправка ответов...",
"takes": "Занимает",
"takes_less_than_x_minutes": "{count, plural, one {Займёт меньше 1 минуты} few {Займёт меньше {count} минут} many {Займёт меньше {count} минут} other {Займёт меньше {count} минуты}}",
"takes_x_minutes": "{count, plural, one {Займёт 1 минуту} 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 {1 минута} few {{count} минуты} many {{count} минут}}",
"x_plus_minutes": "{count}+ минут",
"your_feedback_is_stuck": "Ваш отзыв застрял :("
},
"errors": {
+3 -4
View File
@@ -8,7 +8,6 @@
"company_logo": "Företagslogotyp",
"finish": "Slutför",
"language_switch": "Språkväxlare",
"less_than_x_minutes": "{count, plural, one {mindre än 1 minut} other {mindre än {count} minuter}}",
"next": "Nästa",
"open_in_new_tab": "Öppna i ny flik",
"people_responded": "{count, plural, one {1 person har svarat} other {{count} personer har svarat}}",
@@ -24,12 +23,12 @@
"retry": "Försök igen",
"retrying": "Försöker igen...",
"sending_responses": "Skickar svar...",
"takes": "Tar",
"takes_less_than_x_minutes": "{count, plural, one {Tar mindre än 1 minut} other {Tar mindre än {count} minuter}}",
"takes_x_minutes": "{count, plural, one {Tar 1 minut} other {Tar {count} minuter}}",
"takes_x_plus_minutes": "Tar {count}+ minuter",
"terms_of_service": "Användarvillkor",
"the_servers_cannot_be_reached_at_the_moment": "Servrarna kan inte nås för tillfället.",
"they_will_be_redirected_immediately": "De kommer att omdirigeras omedelbart",
"x_minutes": "{count, plural, one {1 minut} other {{count} minuter}}",
"x_plus_minutes": "{count}+ minuter",
"your_feedback_is_stuck": "Din feedback fastnade :("
},
"errors": {
+3 -4
View File
@@ -8,7 +8,6 @@
"company_logo": "Kompaniya logotipi",
"finish": "Tugatish",
"language_switch": "Tilni almashtirish",
"less_than_x_minutes": "{count, plural, one {1 daqiqadan kam} other {{count} daqiqadan kam}}",
"next": "Keyingisi",
"open_in_new_tab": "Yangi oynada ochish",
"people_responded": "{count, plural, one {1 kishi javob berdi} other {{count} kishi javob berdi}}",
@@ -24,12 +23,12 @@
"retry": "Qayta urinib ko'ring",
"retrying": "Qayta urinilmoqda...",
"sending_responses": "Javoblar yuborilmoqda...",
"takes": "Davomiyligi",
"takes_less_than_x_minutes": "{count, plural, one {1 daqiqadan kam vaqt oladi} other {{count} daqiqadan kam vaqt oladi}}",
"takes_x_minutes": "{count, plural, one {1 daqiqa vaqt oladi} other {{count} daqiqa vaqt oladi}}",
"takes_x_plus_minutes": "{count}+ daqiqa vaqt oladi",
"terms_of_service": "Xizmat ko'rsatish shartlari",
"the_servers_cannot_be_reached_at_the_moment": "Hozirda serverlarga ulanish imkoni yo'q.",
"they_will_be_redirected_immediately": "Ular darhol yo'naltiriladi",
"x_minutes": "{count, plural, one {1 daqiqa} other {{count} daqiqa}}",
"x_plus_minutes": "{count}+ daqiqa",
"your_feedback_is_stuck": "Sizning fikr-mulohazangiz qotib qoldi :("
},
"errors": {
+3 -4
View File
@@ -8,7 +8,6 @@
"company_logo": "公司标志",
"finish": "完成",
"language_switch": "语言切换",
"less_than_x_minutes": "{count, plural, one {少于 1 分钟} other {少于 {count} 分钟}}",
"next": "下一步",
"open_in_new_tab": "在新标签页中打开",
"people_responded": "{count, plural, one {1 人已回应} other {{count} 人已回应}}",
@@ -24,12 +23,12 @@
"retry": "重试",
"retrying": "重试中...",
"sending_responses": "正在发送响应...",
"takes": "需要",
"takes_less_than_x_minutes": "{count, plural, one {少于 1 分钟} other {少于 {count} 分钟}}",
"takes_x_minutes": "{count, plural, one {1 分钟} 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 {1 分钟} other {{count} 分钟}}",
"x_plus_minutes": "{count}+ 分钟",
"your_feedback_is_stuck": "您的反馈卡住了 :("
},
"errors": {
+1 -1
View File
@@ -35,7 +35,7 @@
"clean": "rimraf .turbo node_modules dist",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"i18n:generate": "npx lingo.dev@latest i18n"
"i18n:generate": "npx lingo.dev@latest run && npx lingo.dev@latest lockfile --force"
},
"dependencies": {
"@calcom/embed-snippet": "1.3.3",
@@ -94,7 +94,7 @@ export function WelcomeCard({
const timeInSeconds = (questions.length / idx) * 15; //15 seconds per question.
if (timeInSeconds > 360) {
// If it's more than 6 minutes
return t("common.x_plus_minutes", { count: 6 });
return t("common.takes_x_plus_minutes", { count: 6 });
}
// Calculate minutes, if there are any seconds left, add a minute
const minutes = Math.floor(timeInSeconds / 60);
@@ -104,13 +104,13 @@ export function WelcomeCard({
// If there are any seconds left, we'll need to round up to the next minute
if (minutes === 0) {
// If less than 1 minute, return 'less than 1 minute'
return t("common.less_than_x_minutes", { count: 1 });
return t("common.takes_less_than_x_minutes", { count: 1 });
}
// If more than 1 minute, return 'less than X minutes', where X is minutes + 1
return t("common.less_than_x_minutes", { count: minutes + 1 });
return t("common.takes_less_than_x_minutes", { count: minutes + 1 });
}
// If there are no remaining seconds, just return the number of minutes
return t("common.x_minutes", { count: minutes });
return t("common.takes_x_minutes", { count: minutes });
};
const timeToFinish = survey.welcomeCard.timeToFinish;
@@ -182,7 +182,7 @@ export function WelcomeCard({
<TimerIcon />
<p className="pt-1 text-xs">
<span>
{t("common.takes")} {calculateTimeToComplete()}{" "}
{calculateTimeToComplete()}{" "}
</span>
</p>
</div>
@@ -202,7 +202,7 @@ export function WelcomeCard({
<TimerIcon />
<p className="pt-1 text-xs" data-testid="fb__surveys__welcome-card__info-text-test">
<span>
{t("common.takes")} {calculateTimeToComplete()}{" "}
{calculateTimeToComplete()}{" "}
</span>
<span data-testid="fb__surveys__welcome-card__response-count">
{responseCount && responseCount > 3
+2
View File
@@ -69,6 +69,7 @@ export const ZProject = z.object({
environments: z.array(ZEnvironment),
languages: z.array(ZLanguage),
logo: ZLogo.nullish(),
customHeadScripts: z.string().nullish(),
});
export type TProject = z.infer<typeof ZProject>;
@@ -88,6 +89,7 @@ export const ZProjectUpdateInput = z.object({
styling: ZProjectStyling.optional(),
logo: ZLogo.optional(),
teamIds: z.array(z.string()).optional(),
customHeadScripts: z.string().nullish(),
});
export type TProjectUpdateInput = z.infer<typeof ZProjectUpdateInput>;
+9 -1
View File
@@ -162,7 +162,7 @@ export const ZSurveyCTAElement = ZSurveyElementBase.extend({
buttonUrl: z.string().optional(),
ctaButtonLabel: ZI18nString.optional(),
}).superRefine((data, ctx) => {
// When buttonExternal is true, buttonUrl is required and must be valid
// When buttonExternal is true, buttonUrl and ctaButtonLabel are required
if (data.buttonExternal) {
if (!data.buttonUrl || data.buttonUrl.trim() === "") {
ctx.addIssue({
@@ -181,6 +181,14 @@ export const ZSurveyCTAElement = ZSurveyElementBase.extend({
});
}
}
if (!data.ctaButtonLabel?.default || data.ctaButtonLabel.default.trim() === "") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Button label is required when external button is enabled",
path: ["ctaButtonLabel"],
});
}
}
});
+2
View File
@@ -900,6 +900,8 @@ export const ZSurvey = z
languages: z.array(ZSurveyLanguage),
metadata: ZSurveyMetadata,
slug: ZSurveySlug.nullable(),
customHeadScripts: z.string().nullish(),
customHeadScriptsMode: z.enum(["add", "replace"]).nullish(),
})
.superRefine((survey, ctx) => {
const { questions, blocks, languages, welcomeCard, endings, isBackButtonHidden } = survey;
+671 -361
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -163,6 +163,7 @@
"EMAIL_VERIFICATION_DISABLED",
"ENCRYPTION_KEY",
"ENTERPRISE_LICENSE_KEY",
"ENVIRONMENT",
"GITHUB_ID",
"GITHUB_SECRET",
"GOOGLE_CLIENT_ID",
@@ -274,6 +275,7 @@
"outputs": []
},
"db:seed": {
"env": ["ALLOW_SEED"],
"outputs": []
},
"db:setup": {