mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-21 03:03:25 -05:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d94fbc05f4 | |||
| 541ddc0c4d | |||
| 6d67fc288a | |||
| 63fe32a786 | |||
| 84c465f974 | |||
| 6a33498737 | |||
| 5130c747d4 | |||
| f5583d2652 | |||
| e0d75914a4 | |||
| dffddd0bce | |||
| 4fca961cd5 | |||
| f02ca1cfe1 | |||
| c22a55429d | |||
| 4ade83f189 | |||
| f1fc9fea2c | |||
| 25266e4566 | |||
| b960cfd2a1 | |||
| 9e1d1c1dc2 | |||
| 8c63a9f7af | |||
| dfc86e7dad | |||
| 3b520b5855 | |||
| 4326772989 | |||
| d209e411cb |
@@ -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)
|
||||
|
||||
+1
@@ -213,6 +213,7 @@ export const SurveyAnalysisCTA = ({
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isReadOnly={isReadOnly}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
projectCustomScripts={project.customHeadScripts}
|
||||
/>
|
||||
)}
|
||||
<SuccessMessage environment={environment} survey={survey} />
|
||||
|
||||
+21
-1
@@ -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(() => {
|
||||
|
||||
+163
@@ -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>
|
||||
);
|
||||
};
|
||||
+1
@@ -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 {
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
+237
-216
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
+239
-216
File diff suppressed because it is too large
Load Diff
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}をすべてのフォーム、回答、人物、アクション、属性を含めて削除します。",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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} вместе со всеми опросами, ответами, пользователями, действиями и атрибутами.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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},包括所有调查、回应、人员、动作和属性。",
|
||||
|
||||
@@ -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}(包含所有問卷、回應、人員、操作和屬性)。",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -40,6 +40,8 @@ export const selectSurvey = {
|
||||
isBackButtonHidden: true,
|
||||
metadata: true,
|
||||
slug: true,
|
||||
customHeadScripts: true,
|
||||
customHeadScriptsMode: true,
|
||||
languages: {
|
||||
select: {
|
||||
default: true,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
BIN
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).
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+9
@@ -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';
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 doesn’t 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+671
-361
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user