mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 19:35:53 -05:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8eff2ff877 | |||
| 46be3e7d70 | |||
| 6d140532a7 | |||
| 8c4a7f1518 | |||
| 63fe32a786 | |||
| 84c465f974 | |||
| 6a33498737 | |||
| 5130c747d4 | |||
| f5583d2652 | |||
| e0d75914a4 | |||
| f02ca1cfe1 | |||
| 4ade83f189 | |||
| f1fc9fea2c | |||
| 25266e4566 | |||
| b960cfd2a1 | |||
| 9e1d1c1dc2 | |||
| 8c63a9f7af |
@@ -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 {
|
||||
|
||||
+3
-1
@@ -21,6 +21,7 @@ import {
|
||||
ListOrderedIcon,
|
||||
MessageSquareTextIcon,
|
||||
MousePointerClickIcon,
|
||||
NetworkIcon,
|
||||
PieChartIcon,
|
||||
Rows3Icon,
|
||||
SmartphoneIcon,
|
||||
@@ -99,6 +100,7 @@ const elementIcons = {
|
||||
action: MousePointerClickIcon,
|
||||
country: FlagIcon,
|
||||
url: LinkIcon,
|
||||
ipAddress: NetworkIcon,
|
||||
|
||||
// others
|
||||
Language: LanguagesIcon,
|
||||
@@ -190,7 +192,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
|
||||
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0"
|
||||
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none ring-offset-transparent outline-none focus:border-none focus:shadow-none focus:ring-offset-0 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
|
||||
@@ -82,6 +82,7 @@ const mockPipelineInput = {
|
||||
},
|
||||
country: "USA",
|
||||
action: "Action Name",
|
||||
ipAddress: "203.0.113.7",
|
||||
} as TResponseMeta,
|
||||
personAttributes: {},
|
||||
singleUseId: null,
|
||||
@@ -346,7 +347,7 @@ describe("handleIntegrations", () => {
|
||||
expect(airtableWriteData).toHaveBeenCalledTimes(1);
|
||||
// Adjust expectations for metadata and recalled question
|
||||
const expectedMetadataString =
|
||||
"Source: web\nURL: http://example.com\nBrowser: Chrome\nOS: Mac OS\nDevice: Desktop\nCountry: USA\nAction: Action Name";
|
||||
"Source: web\nURL: http://example.com\nBrowser: Chrome\nOS: Mac OS\nDevice: Desktop\nCountry: USA\nAction: Action Name\nIP Address: 203.0.113.7";
|
||||
expect(airtableWriteData).toHaveBeenCalledWith(
|
||||
mockAirtableIntegration.config.key,
|
||||
mockAirtableIntegration.config.data[0],
|
||||
|
||||
@@ -31,6 +31,7 @@ const convertMetaObjectToString = (metadata: TResponseMeta): string => {
|
||||
if (metadata.userAgent?.device) result.push(`Device: ${metadata.userAgent.device}`);
|
||||
if (metadata.country) result.push(`Country: ${metadata.country}`);
|
||||
if (metadata.action) result.push(`Action: ${metadata.action}`);
|
||||
if (metadata.ipAddress) result.push(`IP Address: ${metadata.ipAddress}`);
|
||||
|
||||
// Join all the elements in the result array with a newline for formatting
|
||||
return result.join("\n");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||
import { headers } from "next/headers";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
@@ -8,6 +9,7 @@ import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { CRON_SECRET } from "@/lib/constants";
|
||||
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
@@ -90,28 +92,50 @@ export const POST = async (request: Request) => {
|
||||
]);
|
||||
};
|
||||
|
||||
const webhookPromises = webhooks.map((webhook) =>
|
||||
fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
webhookId: webhook.id,
|
||||
event,
|
||||
data: {
|
||||
...response,
|
||||
survey: {
|
||||
title: survey.name,
|
||||
type: survey.type,
|
||||
status: survey.status,
|
||||
createdAt: survey.createdAt,
|
||||
updatedAt: survey.updatedAt,
|
||||
},
|
||||
const webhookPromises = webhooks.map((webhook) => {
|
||||
const body = JSON.stringify({
|
||||
webhookId: webhook.id,
|
||||
event,
|
||||
data: {
|
||||
...response,
|
||||
survey: {
|
||||
title: survey.name,
|
||||
type: survey.type,
|
||||
status: survey.status,
|
||||
createdAt: survey.createdAt,
|
||||
updatedAt: survey.updatedAt,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// Generate Standard Webhooks headers
|
||||
const webhookMessageId = uuidv7();
|
||||
const webhookTimestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
"webhook-id": webhookMessageId,
|
||||
"webhook-timestamp": webhookTimestamp.toString(),
|
||||
};
|
||||
|
||||
// Add signature if webhook has a secret configured
|
||||
if (webhook.secret) {
|
||||
requestHeaders["webhook-signature"] = generateStandardWebhookSignature(
|
||||
webhookMessageId,
|
||||
webhookTimestamp,
|
||||
body,
|
||||
webhook.secret
|
||||
);
|
||||
}
|
||||
|
||||
return fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
}).catch((error) => {
|
||||
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
if (event === "responseFinished") {
|
||||
// Fetch integrations and responseCount in parallel
|
||||
|
||||
@@ -11,6 +11,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
@@ -136,6 +137,13 @@ export const POST = withV1ApiWrapper({
|
||||
action: responseInputData?.meta?.action,
|
||||
};
|
||||
|
||||
// Capture IP address if the survey has IP capture enabled
|
||||
// Server-derived IP always overwrites any client-provided value
|
||||
if (survey.isCaptureIpEnabled) {
|
||||
const ipAddress = await getClientIpFromHeaders();
|
||||
meta.ipAddress = ipAddress;
|
||||
}
|
||||
|
||||
response = await createResponseWithQuotaEvaluation({
|
||||
...responseInputData,
|
||||
meta,
|
||||
|
||||
@@ -19,6 +19,10 @@ vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
generateWebhookSecret: vi.fn(() => "whsec_test_secret_1234567890"),
|
||||
}));
|
||||
|
||||
describe("createWebhook", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -59,6 +63,7 @@ describe("createWebhook", () => {
|
||||
source: webhookInput.source,
|
||||
surveyIds: webhookInput.surveyIds,
|
||||
triggers: webhookInput.triggers,
|
||||
secret: "whsec_test_secret_1234567890",
|
||||
environment: {
|
||||
connect: {
|
||||
id: webhookInput.environmentId,
|
||||
@@ -144,6 +149,7 @@ describe("createWebhook", () => {
|
||||
source: webhookInput.source,
|
||||
surveyIds: webhookInput.surveyIds,
|
||||
triggers: webhookInput.triggers,
|
||||
secret: "whsec_test_secret_1234567890",
|
||||
environment: {
|
||||
connect: {
|
||||
id: webhookInput.environmentId,
|
||||
|
||||
@@ -4,12 +4,15 @@ import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TWebhookInput, ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
|
||||
import { ITEMS_PER_PAGE } from "@/lib/constants";
|
||||
import { generateWebhookSecret } from "@/lib/crypto";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhook> => {
|
||||
validateInputs([webhookInput, ZWebhookInput]);
|
||||
|
||||
try {
|
||||
const secret = generateWebhookSecret();
|
||||
|
||||
const createdWebhook = await prisma.webhook.create({
|
||||
data: {
|
||||
url: webhookInput.url,
|
||||
@@ -17,6 +20,7 @@ export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhoo
|
||||
source: webhookInput.source,
|
||||
surveyIds: webhookInput.surveyIds || [],
|
||||
triggers: webhookInput.triggers || [],
|
||||
secret,
|
||||
environment: {
|
||||
connect: {
|
||||
id: webhookInput.environmentId,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
@@ -119,6 +120,13 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
action: responseInputData?.meta?.action,
|
||||
};
|
||||
|
||||
// Capture IP address if the survey has IP capture enabled
|
||||
// Server-derived IP always overwrites any client-provided value
|
||||
if (survey.isCaptureIpEnabled) {
|
||||
const ipAddress = await getClientIpFromHeaders();
|
||||
meta.ipAddress = ipAddress;
|
||||
}
|
||||
|
||||
response = await createResponseWithQuotaEvaluation({
|
||||
...responseInputData,
|
||||
meta,
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
@@ -4913,6 +4913,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
||||
showLanguageSwitch: false,
|
||||
followUps: [],
|
||||
isBackButtonHidden: false,
|
||||
isCaptureIpEnabled: false,
|
||||
metadata: {},
|
||||
questions: [], // Required for build-time type checking (Zod defaults to [] at runtime)
|
||||
slug: null,
|
||||
|
||||
@@ -170,6 +170,7 @@ checksums:
|
||||
common/docs: 1563fcb5ddb5037b0709ccd3dd384a92
|
||||
common/documentation: 1563fcb5ddb5037b0709ccd3dd384a92
|
||||
common/domain: 402d46965eacc3af4c5df92e53e95712
|
||||
common/done: ffd408fa29d5bc9039ef8ea1b9b699bb
|
||||
common/download: 56b7d0834952b39ee394b44bd8179178
|
||||
common/draft: e8a92958ad300aacfe46c2bf6644927e
|
||||
common/duplicate: 27756566785c2b8463e21582c4bb619b
|
||||
@@ -736,20 +737,26 @@ checksums:
|
||||
environments/integrations/webhooks/add_webhook: 20ba6e981d4237490d9da86dade7f7d2
|
||||
environments/integrations/webhooks/add_webhook_description: 85466a73d6a55476319c0c980b6f2aff
|
||||
environments/integrations/webhooks/all_current_and_new_surveys: 4c0e0e94bf2dea0cf58568d11cfbb71d
|
||||
environments/integrations/webhooks/copy_secret_now: 23d6da66a541e7c22eb06729b6eac376
|
||||
environments/integrations/webhooks/created_by_third_party: b40197eabbbce500b80b44268b8b1ee9
|
||||
environments/integrations/webhooks/discord_webhook_not_supported: 23432534f908b2ba63a517fb1f9bbe0e
|
||||
environments/integrations/webhooks/empty_webhook_message: 4c4d8709576a38cb8eb59866331d2405
|
||||
environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6
|
||||
environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027
|
||||
environments/integrations/webhooks/learn_to_verify: 25b2a035e2109170b28f4e16db76ad39
|
||||
environments/integrations/webhooks/please_check_console: 7b1787e82a0d762df02c011ebb1650ea
|
||||
environments/integrations/webhooks/please_enter_a_url: c24c74d0ce7ed3a6b858aadbc82108fe
|
||||
environments/integrations/webhooks/response_created: 8c43b1b6d748f6096f6f8d9232a3c469
|
||||
environments/integrations/webhooks/response_finished: 71764de45369a08aacc290af629fa298
|
||||
environments/integrations/webhooks/response_updated: 0b178ffeb39b615db0db036a685f118b
|
||||
environments/integrations/webhooks/secret_copy_warning: 55ac31fc9ee192a66093ba4b6ccd0a91
|
||||
environments/integrations/webhooks/secret_description: e9ab6e0fd78d49c3e25ee649c62061bd
|
||||
environments/integrations/webhooks/signing_secret: 91594fa8588e4232e155a65d07419bf7
|
||||
environments/integrations/webhooks/source: 6e87903ef260da661b2bf6d858ba68ca
|
||||
environments/integrations/webhooks/test_endpoint: 9ce47af3f982224071e16d5a17190a60
|
||||
environments/integrations/webhooks/triggers: 66488f38662a4199fb8a18967239c992
|
||||
environments/integrations/webhooks/webhook_added_successfully: 2d8e8d7a158ea8e4b65e67900363527b
|
||||
environments/integrations/webhooks/webhook_created: ffb4449a8d50bb83097485ddabb73562
|
||||
environments/integrations/webhooks/webhook_delete_confirmation: b5bae9856effd32053669c0e0a22479f
|
||||
environments/integrations/webhooks/webhook_deleted_successfully: fcefd247ec76a372002d2cffac3c5b0f
|
||||
environments/integrations/webhooks/webhook_name_placeholder: ffa3274cf83d8dc05c882fbf61c48f8f
|
||||
@@ -1119,6 +1126,8 @@ checksums:
|
||||
environments/surveys/edit/cal_username: a4a9c739af909d975beb1bc4998feae9
|
||||
environments/surveys/edit/calculate: c5fcf8d3a38706ae2071b6f78339ec68
|
||||
environments/surveys/edit/capture_a_new_action_to_trigger_a_survey_on: 73410e9665a37bc4a9747db5d683d36c
|
||||
environments/surveys/edit/capture_ip_address: e950f924f1c0b52f8c5b06ca118e049f
|
||||
environments/surveys/edit/capture_ip_address_description: 932d1b4ad68594d06d4eaf0212f9570c
|
||||
environments/surveys/edit/capture_new_action: 0aa2a3c399b62b1a52307deedf4922e8
|
||||
environments/surveys/edit/card_arrangement_for_survey_type_derived: c06b9aaebcc11bc16e57a445b62361fc
|
||||
environments/surveys/edit/card_background_color: acd5d023e1d1a4471b053dce504c7a83
|
||||
@@ -1569,6 +1578,7 @@ checksums:
|
||||
environments/surveys/responses/error_downloading_responses: 97a79108cfc854834d09cf14c300a291
|
||||
environments/surveys/responses/first_name: cf040a5d6a9fd696be400380cc99f54b
|
||||
environments/surveys/responses/how_to_identify_users: c886035d9d9a0cfc3fa9703972001044
|
||||
environments/surveys/responses/ip_address: 8f2b4d42a165a4c165eca4d7639ce57e
|
||||
environments/surveys/responses/last_name: 2c9a7de7738ca007ba9023c385149c26
|
||||
environments/surveys/responses/not_completed: df34eab65a6291f2c5e15a0e349c4eba
|
||||
environments/surveys/responses/os: a4c753bb2c004a58d02faeed6b4da476
|
||||
@@ -1609,6 +1619,20 @@ checksums:
|
||||
environments/surveys/share/anonymous_links/source_tracking: dcf85834f1ba490347a301ab55d32402
|
||||
environments/surveys/share/anonymous_links/url_encryption_description: 1509056fdae7b42fc85f1ee3c49de4c3
|
||||
environments/surveys/share/anonymous_links/url_encryption_label: 9c70fd3f64cf8cc5039b198d3af79d14
|
||||
environments/surveys/share/custom_html/add_mode_description: f48dcf53bce27cc40c3546547e8395cb
|
||||
environments/surveys/share/custom_html/add_to_workspace: af9cd24872f25cfc4231b926acc76d7c
|
||||
environments/surveys/share/custom_html/description: 0634048655de8b4b17b41d496e1ea457
|
||||
environments/surveys/share/custom_html/nav_title: 01f993f027ab277058eacb8a48ea7c01
|
||||
environments/surveys/share/custom_html/no_workspace_scripts: 7fc57f576c98e96ee73e7b489345d51a
|
||||
environments/surveys/share/custom_html/placeholder: 229eb1676a69311ff1dcc19c1a52c080
|
||||
environments/surveys/share/custom_html/replace_mode_description: 6eaf17275c02b0d5ac21255747f36271
|
||||
environments/surveys/share/custom_html/replace_workspace: b80e698cc8790246fea42453bfa4b09d
|
||||
environments/surveys/share/custom_html/saved_successfully: 14e7d2d646803ac1dd24cfa45c22606c
|
||||
environments/surveys/share/custom_html/script_mode: 60ed1102dd42ad14e272df5f6921b423
|
||||
environments/surveys/share/custom_html/security_warning: 5faa0f284d48110918a5e8a467e2bcb8
|
||||
environments/surveys/share/custom_html/survey_scripts_description: 948746d51db23b348164105c175391b3
|
||||
environments/surveys/share/custom_html/survey_scripts_label: 095d9fe768abe2bb32428184ee1c9b5a
|
||||
environments/surveys/share/custom_html/workspace_scripts_label: 3d9b6c09eae10a2bacb3ac96b4db4a19
|
||||
environments/surveys/share/dynamic_popup/alert_button: 8932096e3eee837beeb21dd4afd8b662
|
||||
environments/surveys/share/dynamic_popup/alert_description: 53d2ba39984a059a5eca4cb6cf9ba00d
|
||||
environments/surveys/share/dynamic_popup/alert_title: 813a9160940894da26ec2a09bbb1a7bf
|
||||
@@ -1820,6 +1844,13 @@ checksums:
|
||||
environments/workspace/app-connection/setup_alert_title: 9561cca2b391e0df81e8a982921ff2bb
|
||||
environments/workspace/app-connection/webapp_url: d64d8cc3c4c4ecce780d94755f7e4de9
|
||||
environments/workspace/general/cannot_delete_only_workspace: 853f32a75d92b06eaccc0d43d767c183
|
||||
environments/workspace/general/custom_scripts: a6a06a2e20764d76d3e22e5e17d98dbb
|
||||
environments/workspace/general/custom_scripts_card_description: 1585c47126e4b68f9f79f232631c67a1
|
||||
environments/workspace/general/custom_scripts_description: 1c477e711fc08850b2ab70d98ffe18d6
|
||||
environments/workspace/general/custom_scripts_label: 3b189dd62ae0cc35d616e04af90f0b38
|
||||
environments/workspace/general/custom_scripts_placeholder: 229eb1676a69311ff1dcc19c1a52c080
|
||||
environments/workspace/general/custom_scripts_updated_successfully: eabe8e6ededa86342d59093fe308c681
|
||||
environments/workspace/general/custom_scripts_warning: 5faa0f284d48110918a5e8a467e2bcb8
|
||||
environments/workspace/general/delete_workspace: 3badbc0f4b49644986fc19d8b2d8f317
|
||||
environments/workspace/general/delete_workspace_confirmation: 54a4ee78867537e0244c7170453cdb3f
|
||||
environments/workspace/general/delete_workspace_name_includes_surveys_responses_people_and_more: 11e9ac5a799fbec22495f92f42c40d98
|
||||
|
||||
+131
-1
@@ -1,8 +1,11 @@
|
||||
import * as crypto from "crypto";
|
||||
import * as crypto from "node:crypto";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
// Import after unmocking
|
||||
import {
|
||||
generateStandardWebhookSignature,
|
||||
generateWebhookSecret,
|
||||
getWebhookSecretBytes,
|
||||
hashSecret,
|
||||
hashSha256,
|
||||
parseApiKeyV2,
|
||||
@@ -283,6 +286,133 @@ describe("Crypto Utils", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Webhook Signature Functions", () => {
|
||||
describe("generateWebhookSecret", () => {
|
||||
test("should generate a secret with whsec_ prefix", () => {
|
||||
const secret = generateWebhookSecret();
|
||||
expect(secret.startsWith("whsec_")).toBe(true);
|
||||
});
|
||||
|
||||
test("should generate base64-encoded content after prefix", () => {
|
||||
const secret = generateWebhookSecret();
|
||||
const base64Part = secret.slice(6); // Remove "whsec_"
|
||||
|
||||
// Should be valid base64
|
||||
expect(() => Buffer.from(base64Part, "base64")).not.toThrow();
|
||||
|
||||
// Should decode to 32 bytes (256 bits)
|
||||
const decoded = Buffer.from(base64Part, "base64");
|
||||
expect(decoded.length).toBe(32);
|
||||
});
|
||||
|
||||
test("should generate unique secrets each time", () => {
|
||||
const secret1 = generateWebhookSecret();
|
||||
const secret2 = generateWebhookSecret();
|
||||
expect(secret1).not.toBe(secret2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWebhookSecretBytes", () => {
|
||||
test("should decode whsec_ prefixed secret to bytes", () => {
|
||||
const secret = generateWebhookSecret();
|
||||
const bytes = getWebhookSecretBytes(secret);
|
||||
|
||||
expect(Buffer.isBuffer(bytes)).toBe(true);
|
||||
expect(bytes.length).toBe(32);
|
||||
});
|
||||
|
||||
test("should handle secret without whsec_ prefix", () => {
|
||||
const base64Secret = Buffer.from("test-secret-bytes-32-characters!").toString("base64");
|
||||
const bytes = getWebhookSecretBytes(base64Secret);
|
||||
|
||||
expect(Buffer.isBuffer(bytes)).toBe(true);
|
||||
expect(bytes.toString()).toBe("test-secret-bytes-32-characters!");
|
||||
});
|
||||
|
||||
test("should correctly decode a known secret", () => {
|
||||
// Create a known secret
|
||||
const knownBytes = Buffer.from("known-test-secret-for-testing!!");
|
||||
const secret = `whsec_${knownBytes.toString("base64")}`;
|
||||
|
||||
const decoded = getWebhookSecretBytes(secret);
|
||||
expect(decoded.toString()).toBe("known-test-secret-for-testing!!");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateStandardWebhookSignature", () => {
|
||||
test("should generate signature in v1,{base64} format", () => {
|
||||
const secret = generateWebhookSecret();
|
||||
const signature = generateStandardWebhookSignature("msg_123", 1704547200, '{"test":"data"}', secret);
|
||||
|
||||
expect(signature.startsWith("v1,")).toBe(true);
|
||||
const base64Part = signature.slice(3);
|
||||
expect(() => Buffer.from(base64Part, "base64")).not.toThrow();
|
||||
});
|
||||
|
||||
test("should generate deterministic signatures for same inputs", () => {
|
||||
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
|
||||
const webhookId = "msg_test123";
|
||||
const timestamp = 1704547200;
|
||||
const payload = '{"event":"test"}';
|
||||
|
||||
const sig1 = generateStandardWebhookSignature(webhookId, timestamp, payload, secret);
|
||||
const sig2 = generateStandardWebhookSignature(webhookId, timestamp, payload, secret);
|
||||
|
||||
expect(sig1).toBe(sig2);
|
||||
});
|
||||
|
||||
test("should generate different signatures for different payloads", () => {
|
||||
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
|
||||
const webhookId = "msg_test123";
|
||||
const timestamp = 1704547200;
|
||||
|
||||
const sig1 = generateStandardWebhookSignature(webhookId, timestamp, '{"event":"a"}', secret);
|
||||
const sig2 = generateStandardWebhookSignature(webhookId, timestamp, '{"event":"b"}', secret);
|
||||
|
||||
expect(sig1).not.toBe(sig2);
|
||||
});
|
||||
|
||||
test("should generate different signatures for different timestamps", () => {
|
||||
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
|
||||
const webhookId = "msg_test123";
|
||||
const payload = '{"event":"test"}';
|
||||
|
||||
const sig1 = generateStandardWebhookSignature(webhookId, 1704547200, payload, secret);
|
||||
const sig2 = generateStandardWebhookSignature(webhookId, 1704547201, payload, secret);
|
||||
|
||||
expect(sig1).not.toBe(sig2);
|
||||
});
|
||||
|
||||
test("should generate different signatures for different webhook IDs", () => {
|
||||
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
|
||||
const timestamp = 1704547200;
|
||||
const payload = '{"event":"test"}';
|
||||
|
||||
const sig1 = generateStandardWebhookSignature("msg_1", timestamp, payload, secret);
|
||||
const sig2 = generateStandardWebhookSignature("msg_2", timestamp, payload, secret);
|
||||
|
||||
expect(sig1).not.toBe(sig2);
|
||||
});
|
||||
|
||||
test("should produce verifiable signatures", () => {
|
||||
// This test verifies the signature can be verified using the same algorithm
|
||||
const secretBytes = Buffer.from("test-secret-32-bytes-exactly!!!");
|
||||
const secret = `whsec_${secretBytes.toString("base64")}`;
|
||||
const webhookId = "msg_verify";
|
||||
const timestamp = 1704547200;
|
||||
const payload = '{"event":"verify"}';
|
||||
|
||||
const signature = generateStandardWebhookSignature(webhookId, timestamp, payload, secret);
|
||||
|
||||
// Manually compute the expected signature
|
||||
const signedContent = `${webhookId}.${timestamp}.${payload}`;
|
||||
const expectedSig = crypto.createHmac("sha256", secretBytes).update(signedContent).digest("base64");
|
||||
|
||||
expect(signature).toBe(`v1,${expectedSig}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GCM decryption failure logging", () => {
|
||||
// Test key - 32 bytes for AES-256
|
||||
const testKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
+52
-1
@@ -1,5 +1,5 @@
|
||||
import { compare, hash } from "bcryptjs";
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
|
||||
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
|
||||
@@ -141,3 +141,54 @@ export const parseApiKeyV2 = (key: string): { secret: string } | null => {
|
||||
|
||||
return { secret };
|
||||
};
|
||||
|
||||
// Standard Webhooks secret prefix
|
||||
const WEBHOOK_SECRET_PREFIX = "whsec_";
|
||||
|
||||
/**
|
||||
* Generate a Standard Webhooks compliant secret
|
||||
* Following: https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md
|
||||
*
|
||||
* Format: whsec_ + base64(32 random bytes)
|
||||
* @returns A webhook secret in format "whsec_{base64_encoded_random_bytes}"
|
||||
*/
|
||||
export const generateWebhookSecret = (): string => {
|
||||
const secretBytes = randomBytes(32); // 256 bits of entropy
|
||||
return `${WEBHOOK_SECRET_PREFIX}${secretBytes.toString("base64")}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode a Standard Webhooks secret to get the raw bytes
|
||||
* Strips the whsec_ prefix and base64 decodes the rest
|
||||
*
|
||||
* @param secret The webhook secret (with or without whsec_ prefix)
|
||||
* @returns Buffer containing the raw secret bytes
|
||||
*/
|
||||
export const getWebhookSecretBytes = (secret: string): Buffer => {
|
||||
const base64Part = secret.startsWith(WEBHOOK_SECRET_PREFIX)
|
||||
? secret.slice(WEBHOOK_SECRET_PREFIX.length)
|
||||
: secret;
|
||||
return Buffer.from(base64Part, "base64");
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate Standard Webhooks compliant signature
|
||||
* Following: https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md
|
||||
*
|
||||
* @param webhookId Unique message identifier
|
||||
* @param timestamp Unix timestamp in seconds
|
||||
* @param payload The request body as a string
|
||||
* @param secret The shared secret (whsec_ prefixed)
|
||||
* @returns The signature in format "v1,{base64_signature}"
|
||||
*/
|
||||
export const generateStandardWebhookSignature = (
|
||||
webhookId: string,
|
||||
timestamp: number,
|
||||
payload: string,
|
||||
secret: string
|
||||
): string => {
|
||||
const signedContent = `${webhookId}.${timestamp}.${payload}`;
|
||||
const secretBytes = getWebhookSecretBytes(secret);
|
||||
const signature = createHmac("sha256", secretBytes).update(signedContent).digest("base64");
|
||||
return `v1,${signature}`;
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -208,6 +208,7 @@ const baseSurveyProperties = {
|
||||
},
|
||||
],
|
||||
isBackButtonHidden: false,
|
||||
isCaptureIpEnabled: false,
|
||||
endings: [
|
||||
{
|
||||
id: "umyknohldc7w26ocjdhaa62c",
|
||||
@@ -268,6 +269,8 @@ export const mockSyncSurveyOutput: SurveyMock = {
|
||||
showLanguageSwitch: null,
|
||||
metadata: {},
|
||||
slug: null,
|
||||
customHeadScripts: null,
|
||||
customHeadScriptsMode: null,
|
||||
};
|
||||
|
||||
export const mockSurveyOutput: SurveyMock = {
|
||||
@@ -292,6 +295,8 @@ export const mockSurveyOutput: SurveyMock = {
|
||||
showLanguageSwitch: null,
|
||||
...baseSurveyProperties,
|
||||
slug: null,
|
||||
customHeadScripts: null,
|
||||
customHeadScriptsMode: null,
|
||||
};
|
||||
|
||||
export const createSurveyInput: TSurveyCreateInput = {
|
||||
@@ -322,6 +327,8 @@ export const updateSurveyInput: TSurvey = {
|
||||
...baseSurveyProperties,
|
||||
...commonMockProperties,
|
||||
slug: null,
|
||||
customHeadScripts: null,
|
||||
customHeadScriptsMode: null,
|
||||
};
|
||||
|
||||
export const mockTransformedSurveyOutput = {
|
||||
@@ -574,4 +581,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,
|
||||
};
|
||||
|
||||
@@ -56,6 +56,7 @@ export const selectSurvey = {
|
||||
isVerifyEmailEnabled: true,
|
||||
isSingleResponsePerEmailEnabled: true,
|
||||
isBackButtonHidden: true,
|
||||
isCaptureIpEnabled: true,
|
||||
redirectUrl: true,
|
||||
projectOverwrites: true,
|
||||
styling: true,
|
||||
@@ -65,6 +66,8 @@ export const selectSurvey = {
|
||||
showLanguageSwitch: true,
|
||||
recaptcha: true,
|
||||
metadata: true,
|
||||
customHeadScripts: true,
|
||||
customHeadScriptsMode: true,
|
||||
languages: {
|
||||
select: {
|
||||
default: true,
|
||||
@@ -563,6 +566,7 @@ export const updateSurveyInternal = async (
|
||||
...prismaSurvey, // Properties from prismaSurvey
|
||||
displayPercentage: Number(prismaSurvey.displayPercentage) || null,
|
||||
segment: surveySegment,
|
||||
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
|
||||
};
|
||||
|
||||
return modifiedSurvey;
|
||||
@@ -783,6 +787,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;
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "Dokumentation",
|
||||
"documentation": "Dokumentation",
|
||||
"domain": "Domain",
|
||||
"done": "Fertig",
|
||||
"download": "Herunterladen",
|
||||
"draft": "Entwurf",
|
||||
"duplicate": "Duplikat",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "Webhook hinzufügen",
|
||||
"add_webhook_description": "Sende Umfragedaten an einen benutzerdefinierten Endpunkt",
|
||||
"all_current_and_new_surveys": "Alle aktuellen und neuen Umfragen",
|
||||
"copy_secret_now": "Signierungsschlüssel kopieren",
|
||||
"created_by_third_party": "Erstellt von einer dritten Partei",
|
||||
"discord_webhook_not_supported": "Discord-Webhooks werden derzeit nicht unterstützt.",
|
||||
"empty_webhook_message": "Deine Webhooks werden hier angezeigt, sobald Du sie hinzufügst ⏲️",
|
||||
"endpoint_pinged": "Juhu! Wir können den Webhook anpingen!",
|
||||
"endpoint_pinged_error": "Kann den Webhook nicht anpingen!",
|
||||
"learn_to_verify": "Erfahren Sie, wie Sie Webhook-Signaturen verifizieren",
|
||||
"please_check_console": "Bitte überprüfe die Konsole für weitere Details",
|
||||
"please_enter_a_url": "Bitte gib eine URL ein",
|
||||
"response_created": "Antwort erstellt",
|
||||
"response_finished": "Antwort abgeschlossen",
|
||||
"response_updated": "Antwort aktualisiert",
|
||||
"secret_copy_warning": "Bewahren Sie diesen Schlüssel sicher auf. Sie können ihn erneut in den Webhook-Einstellungen einsehen.",
|
||||
"secret_description": "Verwenden Sie diesen Schlüssel, um Webhook-Anfragen zu verifizieren. Siehe Dokumentation zur Signaturverifizierung.",
|
||||
"signing_secret": "Signierungsschlüssel",
|
||||
"source": "Quelle",
|
||||
"test_endpoint": "Test-Endpunkt",
|
||||
"triggers": "Auslöser",
|
||||
"webhook_added_successfully": "Webhook wurde erfolgreich hinzugefügt",
|
||||
"webhook_created": "Webhook erstellt",
|
||||
"webhook_delete_confirmation": "Bist Du sicher, dass Du diesen Webhook löschen möchtest? Dadurch werden dir keine weiteren Benachrichtigungen mehr gesendet.",
|
||||
"webhook_deleted_successfully": "Webhook erfolgreich gelöscht",
|
||||
"webhook_name_placeholder": "Optional: Benenne deinen Webhook zur einfachen Identifizierung",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Cal.com Benutzername oder Benutzername/Ereignis",
|
||||
"calculate": "Berechnen",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Erfasse eine neue Aktion, um eine Umfrage auszulösen.",
|
||||
"capture_ip_address": "IP-Adresse erfassen",
|
||||
"capture_ip_address_description": "Speichern Sie die IP-Adresse des Befragten in den Antwort-Metadaten zur Duplikaterkennung und für Sicherheitszwecke",
|
||||
"capture_new_action": "Neue Aktion erfassen",
|
||||
"card_arrangement_for_survey_type_derived": "Kartenanordnung für {surveyTypeDerived} Umfragen",
|
||||
"card_background_color": "Hintergrundfarbe der Karte",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "Beim Herunterladen der Antworten ist ein Fehler aufgetreten",
|
||||
"first_name": "Vorname",
|
||||
"how_to_identify_users": "Wie man Benutzer identifiziert",
|
||||
"ip_address": "IP-Adresse",
|
||||
"last_name": "Nachname",
|
||||
"not_completed": "Nicht abgeschlossen ⏳",
|
||||
"os": "Betriebssystem",
|
||||
@@ -1690,6 +1700,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 +1955,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.",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "Documentation",
|
||||
"documentation": "Documentation",
|
||||
"domain": "Domain",
|
||||
"done": "Done",
|
||||
"download": "Download",
|
||||
"draft": "Draft",
|
||||
"duplicate": "Duplicate",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "Add Webhook",
|
||||
"add_webhook_description": "Send survey response data to a custom endpoint",
|
||||
"all_current_and_new_surveys": "All current and new surveys",
|
||||
"copy_secret_now": "Copy your signing secret",
|
||||
"created_by_third_party": "Created by a Third Party",
|
||||
"discord_webhook_not_supported": "Discord webhooks are currently not supported.",
|
||||
"empty_webhook_message": "Your webhooks will appear here as soon as you add them. ⏲️",
|
||||
"endpoint_pinged": "Yay! We are able to ping the webhook!",
|
||||
"endpoint_pinged_error": "Unable to ping the webhook!",
|
||||
"learn_to_verify": "Learn how to verify webhook signatures",
|
||||
"please_check_console": "Please check the console for more details",
|
||||
"please_enter_a_url": "Please enter a URL",
|
||||
"response_created": "Response Created",
|
||||
"response_finished": "Response Finished",
|
||||
"response_updated": "Response Updated",
|
||||
"secret_copy_warning": "Store this secret securely. You can view it again in webhook settings.",
|
||||
"secret_description": "Use this secret to verify webhook requests. See documentation for signature verification.",
|
||||
"signing_secret": "Signing Secret",
|
||||
"source": "Source",
|
||||
"test_endpoint": "Test Endpoint",
|
||||
"triggers": "Triggers",
|
||||
"webhook_added_successfully": "Webhook added successfully",
|
||||
"webhook_created": "Webhook Created",
|
||||
"webhook_delete_confirmation": "Are you sure you want to delete this Webhook? This will stop sending you any further notifications.",
|
||||
"webhook_deleted_successfully": "Webhook deleted successfully",
|
||||
"webhook_name_placeholder": "Optional: Label your webhook for easy identification",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Cal.com username or username/event",
|
||||
"calculate": "Calculate",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Capture a new action to trigger a survey on.",
|
||||
"capture_ip_address": "Capture IP address",
|
||||
"capture_ip_address_description": "Store the respondent's IP address in response metadata for duplicate detection and security purposes",
|
||||
"capture_new_action": "Capture new action",
|
||||
"card_arrangement_for_survey_type_derived": "Card Arrangement for {surveyTypeDerived} Surveys",
|
||||
"card_background_color": "Card background color",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "An error occurred while downloading responses",
|
||||
"first_name": "First Name",
|
||||
"how_to_identify_users": "How to identify users",
|
||||
"ip_address": "IP Address",
|
||||
"last_name": "Last Name",
|
||||
"not_completed": "Not Completed ⏳",
|
||||
"os": "OS",
|
||||
@@ -1690,6 +1700,22 @@
|
||||
"url_encryption_description": "Only disable if you need to set a custom single-use ID.",
|
||||
"url_encryption_label": "URL encryption of single-use ID"
|
||||
},
|
||||
"custom_html": {
|
||||
"add_mode_description": "Survey scripts will run in addition to workspace-level scripts.",
|
||||
"add_to_workspace": "Add to Workspace scripts",
|
||||
"description": "Add tracking scripts and pixels to this survey",
|
||||
"nav_title": "Custom HTML",
|
||||
"no_workspace_scripts": "No workspace-level scripts configured. You can add them in Workspace Settings → General.",
|
||||
"placeholder": "<!-- Paste your tracking scripts here -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||
"replace_mode_description": "Only survey scripts will run. Workspace scripts will be ignored. Keep empty to not load any scripts.",
|
||||
"replace_workspace": "Replace Workspace scripts",
|
||||
"saved_successfully": "Custom scripts saved successfully",
|
||||
"script_mode": "Script Mode",
|
||||
"security_warning": "Scripts execute with full browser access. Only add scripts from trusted sources.",
|
||||
"survey_scripts_description": "Add custom HTML to inject into the <head> of this survey page.",
|
||||
"survey_scripts_label": "Survey-specific scripts",
|
||||
"workspace_scripts_label": "Workspace scripts (inherited)"
|
||||
},
|
||||
"dynamic_popup": {
|
||||
"alert_button": "Edit survey",
|
||||
"alert_description": "This survey is currently configured as a link survey, which does not support dynamic pop-ups. You can change this in the settings tab of the survey editor.",
|
||||
@@ -1929,6 +1955,13 @@
|
||||
},
|
||||
"general": {
|
||||
"cannot_delete_only_workspace": "This is your only workspace, it cannot be deleted. Create a new workspace first.",
|
||||
"custom_scripts": "Custom Scripts",
|
||||
"custom_scripts_card_description": "Add tracking scripts and pixels to all link surveys in this workspace.",
|
||||
"custom_scripts_description": "Scripts will be injected into the <head> of all link survey pages.",
|
||||
"custom_scripts_label": "HTML Scripts",
|
||||
"custom_scripts_placeholder": "<!-- Paste your tracking scripts here -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||
"custom_scripts_updated_successfully": "Custom scripts updated successfully",
|
||||
"custom_scripts_warning": "Scripts execute with full browser access. Only add scripts from trusted sources.",
|
||||
"delete_workspace": "Delete Workspace",
|
||||
"delete_workspace_confirmation": "Are you sure you want to delete {projectName}? This action cannot be undone.",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Delete {projectName} incl. all surveys, responses, people, actions and attributes.",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "Documentación",
|
||||
"documentation": "Documentación",
|
||||
"domain": "Dominio",
|
||||
"done": "Hecho",
|
||||
"download": "Descargar",
|
||||
"draft": "Borrador",
|
||||
"duplicate": "Duplicar",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "Añadir webhook",
|
||||
"add_webhook_description": "Envía datos de respuestas de encuestas a un endpoint personalizado",
|
||||
"all_current_and_new_surveys": "Todas las encuestas actuales y nuevas",
|
||||
"copy_secret_now": "Copia tu secreto de firma",
|
||||
"created_by_third_party": "Creado por un tercero",
|
||||
"discord_webhook_not_supported": "Los webhooks de Discord no son compatibles actualmente.",
|
||||
"empty_webhook_message": "Tus webhooks aparecerán aquí tan pronto como los añadas. ⏲️",
|
||||
"endpoint_pinged": "¡Genial! ¡Podemos hacer ping al webhook!",
|
||||
"endpoint_pinged_error": "¡No se puede hacer ping al webhook!",
|
||||
"learn_to_verify": "Aprende a verificar las firmas de webhook",
|
||||
"please_check_console": "Por favor, consulta la consola para más detalles",
|
||||
"please_enter_a_url": "Por favor, introduce una URL",
|
||||
"response_created": "Respuesta creada",
|
||||
"response_finished": "Respuesta finalizada",
|
||||
"response_updated": "Respuesta actualizada",
|
||||
"secret_copy_warning": "Almacena este secreto de forma segura. Puedes verlo de nuevo en la configuración del webhook.",
|
||||
"secret_description": "Usa este secreto para verificar las solicitudes del webhook. Consulta la documentación para la verificación de firma.",
|
||||
"signing_secret": "Secreto de firma",
|
||||
"source": "Origen",
|
||||
"test_endpoint": "Probar endpoint",
|
||||
"triggers": "Disparadores",
|
||||
"webhook_added_successfully": "Webhook añadido correctamente",
|
||||
"webhook_created": "Webhook creado",
|
||||
"webhook_delete_confirmation": "¿Estás seguro de que quieres eliminar este webhook? Esto detendrá el envío de futuras notificaciones.",
|
||||
"webhook_deleted_successfully": "Webhook eliminado correctamente",
|
||||
"webhook_name_placeholder": "Opcional: Etiqueta tu webhook para identificarlo fácilmente",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Nombre de usuario de Cal.com o nombre de usuario/evento",
|
||||
"calculate": "Calcular",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Captura una nueva acción para activar una encuesta.",
|
||||
"capture_ip_address": "Capturar dirección IP",
|
||||
"capture_ip_address_description": "Almacenar la dirección IP del encuestado en los metadatos de respuesta para la detección de duplicados y fines de seguridad",
|
||||
"capture_new_action": "Capturar nueva acción",
|
||||
"card_arrangement_for_survey_type_derived": "Disposición de tarjetas para encuestas de tipo {surveyTypeDerived}",
|
||||
"card_background_color": "Color de fondo de la tarjeta",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "Se produjo un error al descargar las respuestas",
|
||||
"first_name": "Nombre",
|
||||
"how_to_identify_users": "Cómo identificar a los usuarios",
|
||||
"ip_address": "Dirección IP",
|
||||
"last_name": "Apellido",
|
||||
"not_completed": "No completado ⏳",
|
||||
"os": "Sistema operativo",
|
||||
@@ -1690,6 +1700,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 +1955,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.",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "Documentation",
|
||||
"documentation": "Documentation",
|
||||
"domain": "Domaine",
|
||||
"done": "Terminé",
|
||||
"download": "Télécharger",
|
||||
"draft": "Brouillon",
|
||||
"duplicate": "Dupliquer",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "Ajouter un Webhook",
|
||||
"add_webhook_description": "Envoyer les données de réponse à l'enquête à un point de terminaison personnalisé",
|
||||
"all_current_and_new_surveys": "Tous les sondages actuels et nouveaux",
|
||||
"copy_secret_now": "Copiez votre secret de signature",
|
||||
"created_by_third_party": "Créé par un tiers",
|
||||
"discord_webhook_not_supported": "Les webhooks Discord ne sont actuellement pas pris en charge.",
|
||||
"empty_webhook_message": "Vos webhooks apparaîtront ici dès que vous les ajouterez. ⏲️",
|
||||
"endpoint_pinged": "Yay ! Nous pouvons pinger le webhook !",
|
||||
"endpoint_pinged_error": "Impossible de pinger le webhook !",
|
||||
"learn_to_verify": "Découvrez comment vérifier les signatures de webhook",
|
||||
"please_check_console": "Veuillez vérifier la console pour plus de détails.",
|
||||
"please_enter_a_url": "Veuillez entrer une URL.",
|
||||
"response_created": "Réponse créée",
|
||||
"response_finished": "Réponse terminée",
|
||||
"response_updated": "Réponse mise à jour",
|
||||
"secret_copy_warning": "Conservez ce secret en lieu sûr. Vous pourrez le consulter à nouveau dans les paramètres du webhook.",
|
||||
"secret_description": "Utilisez ce secret pour vérifier les requêtes webhook. Consultez la documentation pour la vérification de signature.",
|
||||
"signing_secret": "Secret de signature",
|
||||
"source": "Source",
|
||||
"test_endpoint": "Point de test",
|
||||
"triggers": "Déclencheurs",
|
||||
"webhook_added_successfully": "Webhook ajouté avec succès",
|
||||
"webhook_created": "Webhook créé",
|
||||
"webhook_delete_confirmation": "Êtes-vous sûr de vouloir supprimer ce Webhook ? Cela arrêtera l'envoi de toute notification future.",
|
||||
"webhook_deleted_successfully": "Webhook supprimé avec succès",
|
||||
"webhook_name_placeholder": "Optionnel : Étiquetez votre webhook pour une identification facile",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Nom d'utilisateur Cal.com ou nom d'utilisateur/événement",
|
||||
"calculate": "Calculer",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Capturez une nouvelle action pour déclencher une enquête.",
|
||||
"capture_ip_address": "Capturer l'adresse IP",
|
||||
"capture_ip_address_description": "Stocker l'adresse IP du répondant dans les métadonnées de réponse à des fins de détection des doublons et de sécurité",
|
||||
"capture_new_action": "Capturer une nouvelle action",
|
||||
"card_arrangement_for_survey_type_derived": "Disposition des cartes pour les enquêtes {surveyTypeDerived}",
|
||||
"card_background_color": "Couleur de fond de la carte",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "Une erreur s'est produite lors du téléchargement des réponses",
|
||||
"first_name": "Prénom",
|
||||
"how_to_identify_users": "Comment identifier les utilisateurs",
|
||||
"ip_address": "Adresse IP",
|
||||
"last_name": "Nom de famille",
|
||||
"not_completed": "Non terminé ⏳",
|
||||
"os": "Système d'exploitation",
|
||||
@@ -1690,6 +1700,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 +1955,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.",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "ドキュメント",
|
||||
"documentation": "ドキュメント",
|
||||
"domain": "ドメイン",
|
||||
"done": "完了",
|
||||
"download": "ダウンロード",
|
||||
"draft": "下書き",
|
||||
"duplicate": "複製",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "Webhook を追加",
|
||||
"add_webhook_description": "フォーム回答データを任意のエンドポイントへ送信",
|
||||
"all_current_and_new_surveys": "現在および新規のすべてのフォーム",
|
||||
"copy_secret_now": "署名シークレットをコピー",
|
||||
"created_by_third_party": "サードパーティによって作成",
|
||||
"discord_webhook_not_supported": "現在、Discord Webhook はサポートしていません。",
|
||||
"empty_webhook_message": "Webhook は追加するとここに表示されます。⏲️",
|
||||
"endpoint_pinged": "成功!Webhook に ping できました。",
|
||||
"endpoint_pinged_error": "Webhook への ping に失敗しました。",
|
||||
"learn_to_verify": "Webhook署名の検証方法を学ぶ",
|
||||
"please_check_console": "詳細はコンソールを確認してください",
|
||||
"please_enter_a_url": "URL を入力してください",
|
||||
"response_created": "回答作成",
|
||||
"response_finished": "回答完了",
|
||||
"response_updated": "回答更新",
|
||||
"secret_copy_warning": "このシークレットを安全に保管してください。Webhook 設定で再度確認できます。",
|
||||
"secret_description": "このシークレットを使用して Webhook リクエストを検証します。署名検証についてはドキュメントを参照してください。",
|
||||
"signing_secret": "署名シークレット",
|
||||
"source": "ソース",
|
||||
"test_endpoint": "エンドポイントをテスト",
|
||||
"triggers": "トリガー",
|
||||
"webhook_added_successfully": "Webhook を追加しました",
|
||||
"webhook_created": "Webhook を作成しました",
|
||||
"webhook_delete_confirmation": "このWebhookを削除してもよろしいですか?以後の通知は送信されません。",
|
||||
"webhook_deleted_successfully": "Webhook を削除しました",
|
||||
"webhook_name_placeholder": "任意: 識別しやすいようWebhookにラベルを付ける",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Cal.comのユーザー名またはユーザー名/イベント",
|
||||
"calculate": "計算",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "フォームをトリガーする新しいアクションをキャプチャします。",
|
||||
"capture_ip_address": "IPアドレスを記録",
|
||||
"capture_ip_address_description": "重複検出とセキュリティ目的で、回答者のIPアドレスを回答メタデータに保存します",
|
||||
"capture_new_action": "新しいアクションをキャプチャ",
|
||||
"card_arrangement_for_survey_type_derived": "{surveyTypeDerived} フォームのカード配置",
|
||||
"card_background_color": "カードの背景色",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "回答のダウンロード中にエラーが発生しました",
|
||||
"first_name": "名",
|
||||
"how_to_identify_users": "ユーザーを識別する方法",
|
||||
"ip_address": "IPアドレス",
|
||||
"last_name": "姓",
|
||||
"not_completed": "未完了 ⏳",
|
||||
"os": "OS",
|
||||
@@ -1690,6 +1700,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 +1955,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}をすべてのフォーム、回答、人物、アクション、属性を含めて削除します。",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "Documentatie",
|
||||
"documentation": "Documentatie",
|
||||
"domain": "Domein",
|
||||
"done": "Klaar",
|
||||
"download": "Downloaden",
|
||||
"draft": "Voorlopige versie",
|
||||
"duplicate": "Duplicaat",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "Webhook toevoegen",
|
||||
"add_webhook_description": "Stuur enquêtereactiegegevens naar een aangepast eindpunt",
|
||||
"all_current_and_new_surveys": "Alle huidige en nieuwe onderzoeken",
|
||||
"copy_secret_now": "Kopieer je ondertekeningsgeheim",
|
||||
"created_by_third_party": "Gemaakt door een derde partij",
|
||||
"discord_webhook_not_supported": "Discord-webhooks worden momenteel niet ondersteund.",
|
||||
"empty_webhook_message": "Uw webhooks verschijnen hier zodra u ze toevoegt. ⏲️",
|
||||
"endpoint_pinged": "Jawel! We kunnen de webhook pingen!",
|
||||
"endpoint_pinged_error": "Kan de webhook niet pingen!",
|
||||
"learn_to_verify": "Leer hoe je webhook-handtekeningen kunt verifiëren",
|
||||
"please_check_console": "Controleer de console voor meer details",
|
||||
"please_enter_a_url": "Voer een URL in",
|
||||
"response_created": "Reactie gemaakt",
|
||||
"response_finished": "Reactie voltooid",
|
||||
"response_updated": "Reactie bijgewerkt",
|
||||
"secret_copy_warning": "Bewaar dit geheim veilig. Je kunt het opnieuw bekijken in de webhook-instellingen.",
|
||||
"secret_description": "Gebruik dit geheim om webhook-verzoeken te verifiëren. Zie de documentatie voor handtekeningverificatie.",
|
||||
"signing_secret": "Ondertekeningsgeheim",
|
||||
"source": "Bron",
|
||||
"test_endpoint": "Eindpunt testen",
|
||||
"triggers": "Triggers",
|
||||
"webhook_added_successfully": "Webhook succesvol toegevoegd",
|
||||
"webhook_created": "Webhook aangemaakt",
|
||||
"webhook_delete_confirmation": "Weet u zeker dat u deze webhook wilt verwijderen? Hierdoor worden er geen verdere meldingen meer verzonden.",
|
||||
"webhook_deleted_successfully": "Webhook is succesvol verwijderd",
|
||||
"webhook_name_placeholder": "Optioneel: Label uw webhook voor gemakkelijke identificatie",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Cal.com-gebruikersnaam of gebruikersnaam/evenement",
|
||||
"calculate": "Berekenen",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Leg een nieuwe actie vast om een enquête over te activeren.",
|
||||
"capture_ip_address": "IP-adres vastleggen",
|
||||
"capture_ip_address_description": "Sla het IP-adres van de respondent op in de metadata van het antwoord voor detectie van duplicaten en beveiligingsdoeleinden",
|
||||
"capture_new_action": "Leg nieuwe actie vast",
|
||||
"card_arrangement_for_survey_type_derived": "Kaartarrangement voor {surveyTypeDerived} enquêtes",
|
||||
"card_background_color": "Achtergrondkleur van de kaart",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "Er is een fout opgetreden bij het downloaden van de antwoorden",
|
||||
"first_name": "Voornaam",
|
||||
"how_to_identify_users": "Hoe gebruikers te identificeren",
|
||||
"ip_address": "IP-adres",
|
||||
"last_name": "Achternaam",
|
||||
"not_completed": "Niet voltooid ⏳",
|
||||
"os": "Besturingssysteem",
|
||||
@@ -1690,6 +1700,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 +1955,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.",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "Documentação",
|
||||
"documentation": "Documentação",
|
||||
"domain": "Domínio",
|
||||
"done": "Concluído",
|
||||
"download": "baixar",
|
||||
"draft": "Rascunho",
|
||||
"duplicate": "Duplicar",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "Adicionar Webhook",
|
||||
"add_webhook_description": "Enviar dados das respostas da pesquisa para um endpoint personalizado",
|
||||
"all_current_and_new_surveys": "Todas as pesquisas atuais e novas",
|
||||
"copy_secret_now": "Copie seu segredo de assinatura",
|
||||
"created_by_third_party": "Criado por um Terceiro",
|
||||
"discord_webhook_not_supported": "Webhooks do Discord não são suportados no momento.",
|
||||
"empty_webhook_message": "Seus webhooks vão aparecer aqui assim que você adicioná-los. ⏲️",
|
||||
"endpoint_pinged": "Uhul! Conseguimos pingar o webhook!",
|
||||
"endpoint_pinged_error": "Não consegui pingar o webhook!",
|
||||
"learn_to_verify": "Aprenda como verificar assinaturas de webhook",
|
||||
"please_check_console": "Por favor, verifica o console para mais detalhes",
|
||||
"please_enter_a_url": "Por favor, insira uma URL",
|
||||
"response_created": "Resposta Criada",
|
||||
"response_finished": "Resposta Finalizada",
|
||||
"response_updated": "Resposta Atualizada",
|
||||
"secret_copy_warning": "Armazene este segredo com segurança. Você pode visualizá-lo novamente nas configurações do webhook.",
|
||||
"secret_description": "Use este segredo para verificar requisições de webhook. Consulte a documentação para verificação de assinatura.",
|
||||
"signing_secret": "Segredo de assinatura",
|
||||
"source": "fonte",
|
||||
"test_endpoint": "Testar Ponto de Extremidade",
|
||||
"triggers": "gatilhos",
|
||||
"webhook_added_successfully": "Webhook adicionado com sucesso",
|
||||
"webhook_created": "Webhook criado",
|
||||
"webhook_delete_confirmation": "Tem certeza de que quer deletar esse Webhook? Isso vai parar de te enviar qualquer notificação.",
|
||||
"webhook_deleted_successfully": "Webhook deletado com sucesso",
|
||||
"webhook_name_placeholder": "Opcional: Dê um nome ao seu webhook para facilitar a identificação",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Nome de usuário do Cal.com ou nome de usuário/evento",
|
||||
"calculate": "Calcular",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Captura uma nova ação pra disparar uma pesquisa.",
|
||||
"capture_ip_address": "Capturar endereço IP",
|
||||
"capture_ip_address_description": "Armazenar o endereço IP do respondente nos metadados da resposta para fins de detecção de duplicatas e segurança",
|
||||
"capture_new_action": "Capturar nova ação",
|
||||
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Pesquisas {surveyTypeDerived}",
|
||||
"card_background_color": "Cor de fundo do cartão",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "Ocorreu um erro ao baixar as respostas",
|
||||
"first_name": "Primeiro Nome",
|
||||
"how_to_identify_users": "Como identificar usuários",
|
||||
"ip_address": "Endereço IP",
|
||||
"last_name": "Sobrenome",
|
||||
"not_completed": "Não Concluído ⏳",
|
||||
"os": "sistema operacional",
|
||||
@@ -1690,6 +1700,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 +1955,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.",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "Documentação",
|
||||
"documentation": "Documentação",
|
||||
"domain": "Domínio",
|
||||
"done": "Concluído",
|
||||
"download": "Transferir",
|
||||
"draft": "Rascunho",
|
||||
"duplicate": "Duplicar",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "Adicionar Webhook",
|
||||
"add_webhook_description": "Enviar dados de resposta do inquérito para um endpoint personalizado",
|
||||
"all_current_and_new_surveys": "Todos os inquéritos atuais e novos",
|
||||
"copy_secret_now": "Copiar o seu segredo de assinatura",
|
||||
"created_by_third_party": "Criado por um Terceiro",
|
||||
"discord_webhook_not_supported": "Os webhooks do Discord não são atualmente suportados.",
|
||||
"empty_webhook_message": "Os seus webhooks aparecerão aqui assim que os adicionar. ⏲️",
|
||||
"endpoint_pinged": "Yay! Conseguimos aceder ao webhook!",
|
||||
"endpoint_pinged_error": "Não foi possível aceder ao webhook!",
|
||||
"learn_to_verify": "Aprenda a verificar assinaturas de webhook",
|
||||
"please_check_console": "Por favor, verifique a consola para mais detalhes",
|
||||
"please_enter_a_url": "Por favor, insira um URL",
|
||||
"response_created": "Resposta Criada",
|
||||
"response_finished": "Resposta Concluída",
|
||||
"response_updated": "Resposta Atualizada",
|
||||
"secret_copy_warning": "Armazene este segredo de forma segura. Pode visualizá-lo novamente nas definições do webhook.",
|
||||
"secret_description": "Use este segredo para verificar os pedidos do webhook. Consulte a documentação para verificação de assinatura.",
|
||||
"signing_secret": "Segredo de assinatura",
|
||||
"source": "Fonte",
|
||||
"test_endpoint": "Testar Endpoint",
|
||||
"triggers": "Disparadores",
|
||||
"webhook_added_successfully": "Webhook adicionado com sucesso",
|
||||
"webhook_created": "Webhook criado",
|
||||
"webhook_delete_confirmation": "Tem a certeza de que deseja eliminar este Webhook? Isto irá parar de lhe enviar quaisquer notificações futuras.",
|
||||
"webhook_deleted_successfully": "Webhook eliminado com sucesso",
|
||||
"webhook_name_placeholder": "Opcional: Rotule o seu webhook para fácil identificação",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Nome de utilizador do Cal.com ou nome de utilizador/evento",
|
||||
"calculate": "Calcular",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Capturar uma nova ação para desencadear um inquérito.",
|
||||
"capture_ip_address": "Capturar endereço IP",
|
||||
"capture_ip_address_description": "Armazenar o endereço IP do inquirido nos metadados da resposta para deteção de duplicados e fins de segurança",
|
||||
"capture_new_action": "Capturar nova ação",
|
||||
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Inquéritos {surveyTypeDerived}",
|
||||
"card_background_color": "Cor de fundo do cartão",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "Ocorreu um erro ao transferir as respostas",
|
||||
"first_name": "Primeiro Nome",
|
||||
"how_to_identify_users": "Como identificar utilizadores",
|
||||
"ip_address": "Endereço IP",
|
||||
"last_name": "Apelido",
|
||||
"not_completed": "Não Concluído ⏳",
|
||||
"os": "SO",
|
||||
@@ -1690,6 +1700,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 +1955,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.",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "Documentație",
|
||||
"documentation": "Documentație",
|
||||
"domain": "Domeniu",
|
||||
"done": "Gata",
|
||||
"download": "Descărcare",
|
||||
"draft": "Schiță",
|
||||
"duplicate": "Duplicități",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "Adaugă Webhook",
|
||||
"add_webhook_description": "Trimite datele de răspuns ale chestionarului la un punct final personalizat",
|
||||
"all_current_and_new_surveys": "Toate chestionarele curente și noi",
|
||||
"copy_secret_now": "Copiază secretul de semnare",
|
||||
"created_by_third_party": "Creat de o Parte Terță",
|
||||
"discord_webhook_not_supported": "Webhook-urile Discord nu sunt în prezent suportate.",
|
||||
"empty_webhook_message": "Webhook-urile tale vor apărea aici de îndată ce le vei adăuga. ⏲️",
|
||||
"endpoint_pinged": "Grozav! Am reușit să ping-ui webhooks-ul!",
|
||||
"endpoint_pinged_error": "Nu pot să ping-ui webhooks-ul!",
|
||||
"learn_to_verify": "Află cum să verifici semnăturile webhook",
|
||||
"please_check_console": "Vă rugăm să verificați consola pentru mai multe detalii",
|
||||
"please_enter_a_url": "Vă rugăm să introduceți un URL",
|
||||
"response_created": "Răspuns creat",
|
||||
"response_finished": "Răspuns finalizat",
|
||||
"response_updated": "Răspuns actualizat",
|
||||
"secret_copy_warning": "Păstrează acest secret în siguranță. Îl poți vizualiza din nou în setările webhook-ului.",
|
||||
"secret_description": "Folosește acest secret pentru a verifica cererile webhook. Vezi documentația pentru verificarea semnăturii.",
|
||||
"signing_secret": "Secret de semnare",
|
||||
"source": "Sursă",
|
||||
"test_endpoint": "Punct final de test",
|
||||
"triggers": "Declanșatori",
|
||||
"webhook_added_successfully": "Webhook adăugat cu succes",
|
||||
"webhook_created": "Webhook creat",
|
||||
"webhook_delete_confirmation": "Sigur doriți să ștergeți acest Webhook? Acest lucru va opri trimiterea oricăror notificări viitoare.",
|
||||
"webhook_deleted_successfully": "Webhook șters cu succes",
|
||||
"webhook_name_placeholder": "Opțional: Etichetează webhook-ul pentru identificare ușoară",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Utilizator Cal.com sau utilizator/eveniment",
|
||||
"calculate": "Calculați",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Capturează o acțiune nouă pentru a declanșa un sondaj.",
|
||||
"capture_ip_address": "Capturare adresă IP",
|
||||
"capture_ip_address_description": "Stochează adresa IP a respondentului în metadatele răspunsului pentru detectarea duplicatelor și în scopuri de securitate",
|
||||
"capture_new_action": "Capturați acțiune nouă",
|
||||
"card_arrangement_for_survey_type_derived": "Aranjament de carduri pentru sondaje de tip {surveyTypeDerived}",
|
||||
"card_background_color": "Culoarea de fundal a cardului",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "A apărut o eroare la descărcarea răspunsurilor",
|
||||
"first_name": "Prenume",
|
||||
"how_to_identify_users": "Cum să identifici utilizatorii",
|
||||
"ip_address": "Adresă IP",
|
||||
"last_name": "Nume de familie",
|
||||
"not_completed": "Necompletat ⏳",
|
||||
"os": "SO",
|
||||
@@ -1690,6 +1700,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 +1955,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.",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "Документация",
|
||||
"documentation": "Документация",
|
||||
"domain": "Домен",
|
||||
"done": "Готово",
|
||||
"download": "Скачать",
|
||||
"draft": "Черновик",
|
||||
"duplicate": "Дублировать",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "Добавить webhook",
|
||||
"add_webhook_description": "Отправляйте данные ответов на опрос на пользовательский endpoint",
|
||||
"all_current_and_new_surveys": "Все текущие и новые опросы",
|
||||
"copy_secret_now": "Скопируйте ваш секрет подписи",
|
||||
"created_by_third_party": "Создано сторонней организацией",
|
||||
"discord_webhook_not_supported": "В настоящее время webhooks Discord не поддерживаются.",
|
||||
"empty_webhook_message": "Ваши webhooks появятся здесь, как только вы их добавите. ⏲️",
|
||||
"endpoint_pinged": "Ура! Нам удалось отправить ping на webhook!",
|
||||
"endpoint_pinged_error": "Не удалось отправить ping на webhook!",
|
||||
"learn_to_verify": "Узнайте, как проверить подписи вебхуков",
|
||||
"please_check_console": "Пожалуйста, проверьте консоль для получения подробностей",
|
||||
"please_enter_a_url": "Пожалуйста, введите URL",
|
||||
"response_created": "Ответ создан",
|
||||
"response_finished": "Ответ завершён",
|
||||
"response_updated": "Ответ обновлён",
|
||||
"secret_copy_warning": "Храните этот секрет в надёжном месте. Вы сможете просмотреть его снова в настройках webhook.",
|
||||
"secret_description": "Используйте этот секрет для проверки запросов webhook. Подробнее о проверке подписи — в документации.",
|
||||
"signing_secret": "Секрет подписи",
|
||||
"source": "Источник",
|
||||
"test_endpoint": "Тестировать endpoint",
|
||||
"triggers": "Триггеры",
|
||||
"webhook_added_successfully": "Webhook успешно добавлен",
|
||||
"webhook_created": "Webhook создан",
|
||||
"webhook_delete_confirmation": "Вы уверены, что хотите удалить этот webhook? Это прекратит отправку вам любых дальнейших уведомлений.",
|
||||
"webhook_deleted_successfully": "Webhook успешно удалён",
|
||||
"webhook_name_placeholder": "Необязательно: дайте метку вашему webhook для удобной идентификации",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Имя пользователя Cal.com или username/event",
|
||||
"calculate": "Вычислить",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Захватить новое действие для запуска опроса.",
|
||||
"capture_ip_address": "Сохранять IP-адрес",
|
||||
"capture_ip_address_description": "Сохранять IP-адрес респондента в метаданных ответа для обнаружения дубликатов и обеспечения безопасности",
|
||||
"capture_new_action": "Захватить новое действие",
|
||||
"card_arrangement_for_survey_type_derived": "Расположение карточек для опросов типа {surveyTypeDerived}",
|
||||
"card_background_color": "Цвет фона карточки",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "Произошла ошибка при загрузке ответов",
|
||||
"first_name": "Имя",
|
||||
"how_to_identify_users": "Как идентифицировать пользователей",
|
||||
"ip_address": "IP-адрес",
|
||||
"last_name": "Фамилия",
|
||||
"not_completed": "Не завершено ⏳",
|
||||
"os": "ОС",
|
||||
@@ -1690,6 +1700,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 +1955,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} вместе со всеми опросами, ответами, пользователями, действиями и атрибутами.",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "Dokumentation",
|
||||
"documentation": "Dokumentation",
|
||||
"domain": "Domän",
|
||||
"done": "Klar",
|
||||
"download": "Ladda ner",
|
||||
"draft": "Utkast",
|
||||
"duplicate": "Duplicera",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "Lägg till webhook",
|
||||
"add_webhook_description": "Skicka enkätsvardata till en anpassad endpoint",
|
||||
"all_current_and_new_surveys": "Alla nuvarande och nya enkäter",
|
||||
"copy_secret_now": "Kopiera din signeringsnyckel",
|
||||
"created_by_third_party": "Skapad av tredje part",
|
||||
"discord_webhook_not_supported": "Discord-webhooks stöds för närvarande inte.",
|
||||
"empty_webhook_message": "Dina webhooks visas här så snart du lägger till dem. ⏲️",
|
||||
"endpoint_pinged": "Ja! Vi kan nå webhooken!",
|
||||
"endpoint_pinged_error": "Kunde inte nå webhooken!",
|
||||
"learn_to_verify": "Lär dig hur du verifierar webhook-signaturer",
|
||||
"please_check_console": "Vänligen kontrollera konsolen för mer information",
|
||||
"please_enter_a_url": "Vänligen ange en URL",
|
||||
"response_created": "Svar skapat",
|
||||
"response_finished": "Svar slutfört",
|
||||
"response_updated": "Svar uppdaterat",
|
||||
"secret_copy_warning": "Förvara denna nyckel säkert. Du kan visa den igen i webhook-inställningarna.",
|
||||
"secret_description": "Använd denna nyckel för att verifiera webhook-förfrågningar. Se dokumentationen för signaturverifiering.",
|
||||
"signing_secret": "Signeringsnyckel",
|
||||
"source": "Källa",
|
||||
"test_endpoint": "Testa endpoint",
|
||||
"triggers": "Utlösare",
|
||||
"webhook_added_successfully": "Webhook tillagd",
|
||||
"webhook_created": "Webhook skapad",
|
||||
"webhook_delete_confirmation": "Är du säker på att du vill ta bort denna webhook? Detta kommer att stoppa alla ytterligare notifieringar.",
|
||||
"webhook_deleted_successfully": "Webhook borttagen",
|
||||
"webhook_name_placeholder": "Valfritt: Namnge din webhook för enkel identifiering",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Cal.com-användarnamn eller användarnamn/händelse",
|
||||
"calculate": "Beräkna",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Fånga en ny åtgärd att utlösa en enkät på.",
|
||||
"capture_ip_address": "Registrera IP-adress",
|
||||
"capture_ip_address_description": "Spara respondentens IP-adress i svarsmetadatan för att upptäcka dubbletter och av säkerhetsskäl",
|
||||
"capture_new_action": "Fånga ny åtgärd",
|
||||
"card_arrangement_for_survey_type_derived": "Kortarrangemang för {surveyTypeDerived}-enkäter",
|
||||
"card_background_color": "Kortets bakgrundsfärg",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "Ett fel uppstod vid nedladdning av svar",
|
||||
"first_name": "Förnamn",
|
||||
"how_to_identify_users": "Hur man identifierar användare",
|
||||
"ip_address": "IP-adress",
|
||||
"last_name": "Efternamn",
|
||||
"not_completed": "Inte slutförd ⏳",
|
||||
"os": "OS",
|
||||
@@ -1690,6 +1700,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 +1955,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.",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "文档",
|
||||
"documentation": "文档",
|
||||
"domain": "域名",
|
||||
"done": "完成",
|
||||
"download": "下载",
|
||||
"draft": "草稿",
|
||||
"duplicate": "复制",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "添加 Webhook",
|
||||
"add_webhook_description": "发送 调查 响应 数据 到 自定义 端点",
|
||||
"all_current_and_new_surveys": "所有 当前 和 新的 调查",
|
||||
"copy_secret_now": "复制您的签名密钥",
|
||||
"created_by_third_party": "由 第三方 创建",
|
||||
"discord_webhook_not_supported": "Discord webhooks 目前不 支持。",
|
||||
"empty_webhook_message": "您的 Webhooks 会在您 添加 后 出现在这里。 ⏲️",
|
||||
"endpoint_pinged": "太好了! 我们能 ping 该 webhook!",
|
||||
"endpoint_pinged_error": "无法 ping 该 webhook!",
|
||||
"learn_to_verify": "了解如何验证 webhook 签名",
|
||||
"please_check_console": "请查看控制台以获取更多详情",
|
||||
"please_enter_a_url": "请输入一个 URL",
|
||||
"response_created": "创建 响应",
|
||||
"response_finished": "响应 完成",
|
||||
"response_updated": "更新 响应",
|
||||
"secret_copy_warning": "请妥善保存此密钥。您可以在 Webhook 设置中再次查看。",
|
||||
"secret_description": "使用此密钥验证 Webhook 请求。有关签名验证,请参阅文档。",
|
||||
"signing_secret": "签名密钥",
|
||||
"source": "来源",
|
||||
"test_endpoint": "测试 端点",
|
||||
"triggers": "触发器",
|
||||
"webhook_added_successfully": "Webhook 添加成功",
|
||||
"webhook_created": "Webhook 已创建",
|
||||
"webhook_delete_confirmation": "您 确定 要 删除 此 Webhook 吗?这 将 停止 向 您 发送 更多 通知 。",
|
||||
"webhook_deleted_successfully": "Webhook 删除 成功",
|
||||
"webhook_name_placeholder": "可选 : 为 您的 Webhook 标注 标签 以 便于 识别",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Cal.com 用户名 或 用户名/事件",
|
||||
"calculate": "计算",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "捕获一个新动作以触发调查。",
|
||||
"capture_ip_address": "记录IP地址",
|
||||
"capture_ip_address_description": "将答题者的IP地址存储在响应元数据中,用于重复检测和安全目的",
|
||||
"capture_new_action": "捕获 新动作",
|
||||
"card_arrangement_for_survey_type_derived": "{surveyTypeDerived} 调查 的 卡片 布局",
|
||||
"card_background_color": "卡片 的 背景 颜色",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "下载答复时发生错误",
|
||||
"first_name": "名字",
|
||||
"how_to_identify_users": "如何 识别 用户",
|
||||
"ip_address": "IP地址",
|
||||
"last_name": "姓",
|
||||
"not_completed": "未完成 ⏳",
|
||||
"os": "操作系统",
|
||||
@@ -1690,6 +1700,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 +1955,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},包括所有调查、回应、人员、动作和属性。",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "文件",
|
||||
"documentation": "文件",
|
||||
"domain": "網域",
|
||||
"done": "完成",
|
||||
"download": "下載",
|
||||
"draft": "草稿",
|
||||
"duplicate": "複製",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "新增 Webhook",
|
||||
"add_webhook_description": "將問卷回應資料傳送至自訂端點",
|
||||
"all_current_and_new_surveys": "所有目前和新的問卷",
|
||||
"copy_secret_now": "複製您的簽章密鑰",
|
||||
"created_by_third_party": "由第三方建立",
|
||||
"discord_webhook_not_supported": "目前不支援 Discord webhooks。",
|
||||
"empty_webhook_message": "您的 Webhook 將在您新增後立即顯示在此處。⏲️",
|
||||
"endpoint_pinged": "耶!我們能夠 ping Webhook!",
|
||||
"endpoint_pinged_error": "無法 ping Webhook!",
|
||||
"learn_to_verify": "了解如何驗證 webhook 簽章",
|
||||
"please_check_console": "請檢查主控台以取得更多詳細資料",
|
||||
"please_enter_a_url": "請輸入網址",
|
||||
"response_created": "已建立回應",
|
||||
"response_finished": "已完成回應",
|
||||
"response_updated": "已更新回應",
|
||||
"secret_copy_warning": "請妥善保存此密鑰。您可以在 Webhook 設定中再次查看。",
|
||||
"secret_description": "使用此密鑰來驗證 Webhook 請求。請參閱文件以了解簽章驗證方式。",
|
||||
"signing_secret": "簽章密鑰",
|
||||
"source": "來源",
|
||||
"test_endpoint": "測試端點",
|
||||
"triggers": "觸發器",
|
||||
"webhook_added_successfully": "Webhook 已成功新增",
|
||||
"webhook_created": "Webhook 已建立",
|
||||
"webhook_delete_confirmation": "您確定要刪除此 Webhook 嗎?這將停止向您發送任何進一步的通知。",
|
||||
"webhook_deleted_successfully": "Webhook 已成功刪除",
|
||||
"webhook_name_placeholder": "選填:為您的 Webhook 加上標籤以便於識別",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Cal.com 使用者名稱或使用者名稱/事件",
|
||||
"calculate": "計算",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "擷取新的操作以觸發問卷。",
|
||||
"capture_ip_address": "擷取 IP 位址",
|
||||
"capture_ip_address_description": "將受訪者的 IP 位址儲存在回應中繼資料中,以便進行重複檢測與安全性用途",
|
||||
"capture_new_action": "擷取新操作",
|
||||
"card_arrangement_for_survey_type_derived": "'{'surveyTypeDerived'}' 問卷的卡片排列",
|
||||
"card_background_color": "卡片背景顏色",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "下載回應時發生錯誤",
|
||||
"first_name": "名字",
|
||||
"how_to_identify_users": "如何識別使用者",
|
||||
"ip_address": "IP 位址",
|
||||
"last_name": "姓氏",
|
||||
"not_completed": "未完成 ⏳",
|
||||
"os": "作業系統",
|
||||
@@ -1690,6 +1700,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 +1955,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}(包含所有問卷、回應、人員、操作和屬性)。",
|
||||
|
||||
+5
@@ -123,6 +123,11 @@ export const SingleResponseCardMetadata = ({ response, locale }: SingleResponseC
|
||||
{t("environments.surveys.responses.country")}: {response.meta.country}
|
||||
</p>
|
||||
)}
|
||||
{response.meta.ipAddress && (
|
||||
<p className="truncate" title={`IP Address: ${response.meta.ipAddress}`}>
|
||||
{t("environments.surveys.responses.ip_address")}: {response.meta.ipAddress}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export const ZWebhookUpdateSchema = ZWebhook.omit({
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
environmentId: true,
|
||||
secret: true,
|
||||
}).openapi({
|
||||
ref: "webhookUpdate",
|
||||
description: "A webhook to update.",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Prisma, Webhook } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { generateWebhookSecret } from "@/lib/crypto";
|
||||
import { getWebhooksQuery } from "@/modules/api/v2/management/webhooks/lib/utils";
|
||||
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
@@ -49,6 +50,8 @@ export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webh
|
||||
const { environmentId, name, url, source, triggers, surveyIds } = webhook;
|
||||
|
||||
try {
|
||||
const secret = generateWebhookSecret();
|
||||
|
||||
const prismaData: Prisma.WebhookCreateInput = {
|
||||
environment: {
|
||||
connect: {
|
||||
@@ -60,6 +63,7 @@ export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webh
|
||||
source,
|
||||
triggers,
|
||||
surveyIds,
|
||||
secret,
|
||||
};
|
||||
|
||||
const createdWebhook = await prisma.webhook.create({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { PipelineTriggers } from "@prisma/client";
|
||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||
import clsx from "clsx";
|
||||
import { Webhook } from "lucide-react";
|
||||
import { Webhook as WebhookIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -12,6 +12,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { SurveyCheckboxGroup } from "@/modules/integrations/webhooks/components/survey-checkbox-group";
|
||||
import { TriggerCheckboxGroup } from "@/modules/integrations/webhooks/components/trigger-checkbox-group";
|
||||
import { WebhookCreatedModal } from "@/modules/integrations/webhooks/components/webhook-created-modal";
|
||||
import { isDiscordWebhook, validWebHookURL } from "@/modules/integrations/webhooks/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -51,6 +52,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
||||
const [selectedSurveys, setSelectedSurveys] = useState<string[]>([]);
|
||||
const [selectedAllSurveys, setSelectedAllSurveys] = useState(false);
|
||||
const [creatingWebhook, setCreatingWebhook] = useState(false);
|
||||
const [createdWebhook, setCreatedWebhook] = useState<Webhook | null>(null);
|
||||
|
||||
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
|
||||
try {
|
||||
@@ -142,7 +144,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
||||
});
|
||||
if (createWebhookActionResult?.data) {
|
||||
router.refresh();
|
||||
setOpenWithStates(false);
|
||||
setCreatedWebhook(createWebhookActionResult.data);
|
||||
toast.success(t("environments.integrations.webhooks.webhook_added_successfully"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createWebhookActionResult);
|
||||
@@ -156,21 +158,27 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
||||
}
|
||||
};
|
||||
|
||||
const setOpenWithStates = (isOpen: boolean) => {
|
||||
setOpen(isOpen);
|
||||
const resetAndClose = () => {
|
||||
setOpen(false);
|
||||
reset();
|
||||
setTestEndpointInput("");
|
||||
setEndpointAccessible(undefined);
|
||||
setSelectedSurveys([]);
|
||||
setSelectedTriggers([]);
|
||||
setSelectedAllSurveys(false);
|
||||
setCreatedWebhook(null);
|
||||
};
|
||||
|
||||
// Show success dialog with secret after webhook creation
|
||||
if (createdWebhook) {
|
||||
return <WebhookCreatedModal open={open} webhook={createdWebhook} onClose={resetAndClose} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpenWithStates}>
|
||||
<Dialog open={open} onOpenChange={resetAndClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<Webhook />
|
||||
<WebhookIcon />
|
||||
<DialogTitle>{t("environments.integrations.webhooks.add_webhook")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.integrations.webhooks.add_webhook_description")}
|
||||
@@ -249,12 +257,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setOpenWithStates(false);
|
||||
}}>
|
||||
<Button type="button" variant="secondary" onClick={resetAndClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={creatingWebhook}>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { Webhook } from "@prisma/client";
|
||||
import { CheckIcon, CopyIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface WebhookCreatedModalProps {
|
||||
open: boolean;
|
||||
webhook: Webhook;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const WebhookCreatedModal = ({ open, webhook, onClose }: WebhookCreatedModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<CheckIcon className="h-6 w-6 text-green-500" />
|
||||
<DialogTitle>{t("environments.integrations.webhooks.webhook_created")}</DialogTitle>
|
||||
<DialogDescription>{t("environments.integrations.webhooks.copy_secret_now")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="space-y-4 pb-4">
|
||||
<div className="col-span-1">
|
||||
<Label>{t("environments.integrations.webhooks.signing_secret")}</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Input type="text" readOnly value={webhook.secret ?? ""} className="font-mono text-sm" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="ml-2 whitespace-nowrap"
|
||||
onClick={() => copyToClipboard(webhook.secret ?? "")}>
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
{t("common.copied")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
{t("common.copy")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
{t("environments.integrations.webhooks.secret_copy_warning")}
|
||||
</p>
|
||||
<Link
|
||||
href="https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks#webhook-security-with-standard-webhooks"
|
||||
target="_blank"
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs text-slate-600 underline hover:text-slate-800">
|
||||
{t("environments.integrations.webhooks.learn_to_verify")}
|
||||
<ExternalLinkIcon className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={onClose}>
|
||||
{t("common.done")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||
import clsx from "clsx";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { CheckIcon, CopyIcon, ExternalLinkIcon, EyeIcon, EyeOff, TrashIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
@@ -48,6 +48,15 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
|
||||
const [endpointAccessible, setEndpointAccessible] = useState<boolean>();
|
||||
const [hittingEndpoint, setHittingEndpoint] = useState<boolean>(false);
|
||||
const [selectedAllSurveys, setSelectedAllSurveys] = useState(webhook.surveyIds.length === 0);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
|
||||
try {
|
||||
@@ -113,6 +122,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
|
||||
toast.error(t("common.please_select_at_least_one_survey"));
|
||||
return;
|
||||
}
|
||||
|
||||
const endpointHitSuccessfully = await handleTestEndpoint(false);
|
||||
if (!endpointHitSuccessfully) {
|
||||
return;
|
||||
@@ -196,6 +206,60 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{webhook.secret && (
|
||||
<div className="col-span-1">
|
||||
<Label htmlFor="secret">{t("environments.integrations.webhooks.signing_secret")}</Label>
|
||||
<div className="mt-1 flex">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showSecret ? "text" : "password"}
|
||||
id="secret"
|
||||
readOnly
|
||||
value={webhook.secret}
|
||||
className="pr-10 font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 transform"
|
||||
onClick={() => setShowSecret(!showSecret)}>
|
||||
{showSecret ? (
|
||||
<EyeOff className="h-5 w-5 text-slate-400" />
|
||||
) : (
|
||||
<EyeIcon className="h-5 w-5 text-slate-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="ml-2 whitespace-nowrap"
|
||||
onClick={() => copyToClipboard(webhook.secret ?? "")}>
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
{t("common.copied")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
{t("common.copy")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{t("environments.integrations.webhooks.secret_description")}
|
||||
</p>
|
||||
<Link
|
||||
href="https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks#webhook-security-with-standard-webhooks"
|
||||
target="_blank"
|
||||
className="mt-1 inline-flex items-center gap-1 text-xs text-slate-600 underline hover:text-slate-800">
|
||||
{t("environments.integrations.webhooks.learn_to_verify")}
|
||||
<ExternalLinkIcon className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Triggers">{t("environments.integrations.webhooks.triggers")}</Label>
|
||||
<TriggerCheckboxGroup
|
||||
|
||||
@@ -35,6 +35,7 @@ export const WebhookTable = ({
|
||||
surveyIds: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
secret: null,
|
||||
});
|
||||
|
||||
const handleOpenWebhookDetailModalClick = (e, webhook: Webhook) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Prisma, Webhook } from "@prisma/client";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
ResourceNotFoundError,
|
||||
UnknownError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
|
||||
import { TWebhookInput } from "../types/webhooks";
|
||||
@@ -59,15 +61,19 @@ export const deleteWebhook = async (id: string): Promise<boolean> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<boolean> => {
|
||||
export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<Webhook> => {
|
||||
try {
|
||||
if (isDiscordWebhook(webhookInput.url)) {
|
||||
throw new UnknownError("Discord webhooks are currently not supported.");
|
||||
}
|
||||
await prisma.webhook.create({
|
||||
|
||||
const secret = generateWebhookSecret();
|
||||
|
||||
const webhook = await prisma.webhook.create({
|
||||
data: {
|
||||
...webhookInput,
|
||||
surveyIds: webhookInput.surveyIds || [],
|
||||
secret,
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
@@ -76,7 +82,7 @@ export const createWebhook = async (environmentId: string, webhookInput: TWebhoo
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
return webhook;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
@@ -121,13 +127,22 @@ export const testEndpoint = async (url: string): Promise<boolean> => {
|
||||
throw new UnknownError("Discord webhooks are currently not supported.");
|
||||
}
|
||||
|
||||
const webhookMessageId = uuidv7();
|
||||
const webhookTimestamp = Math.floor(Date.now() / 1000);
|
||||
const body = JSON.stringify({ event: "testEndpoint" });
|
||||
|
||||
// Generate a temporary test secret and signature for consistency with actual webhooks
|
||||
const testSecret = generateWebhookSecret();
|
||||
const signature = generateStandardWebhookSignature(webhookMessageId, webhookTimestamp, body, testSecret);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
event: "testEndpoint",
|
||||
}),
|
||||
body,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"webhook-id": webhookMessageId,
|
||||
"webhook-timestamp": webhookTimestamp.toString(),
|
||||
"webhook-signature": signature,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -33,9 +33,10 @@ export const ResponseOptionsCard = ({
|
||||
const [surveyClosedMessageToggle, setSurveyClosedMessageToggle] = useState(false);
|
||||
const [verifyEmailToggle, setVerifyEmailToggle] = useState(localSurvey.isVerifyEmailEnabled);
|
||||
const [recaptchaToggle, setRecaptchaToggle] = useState(localSurvey.recaptcha?.enabled ?? false);
|
||||
const [isSingleResponsePerEmailEnabledToggle, setIsSingleResponsePerEmailToggle] = useState(
|
||||
const [singleResponsePerEmailToggle, setSingleResponsePerEmailToggle] = useState(
|
||||
localSurvey.isSingleResponsePerEmailEnabled
|
||||
);
|
||||
const [captureIpToggle, setCaptureIpToggle] = useState(localSurvey.isCaptureIpEnabled);
|
||||
|
||||
const [surveyClosedMessage, setSurveyClosedMessage] = useState({
|
||||
heading: t("environments.surveys.edit.survey_completed_heading"),
|
||||
@@ -90,7 +91,7 @@ export const ResponseOptionsCard = ({
|
||||
};
|
||||
|
||||
const handleSingleResponsePerEmailToggle = () => {
|
||||
setIsSingleResponsePerEmailToggle(!isSingleResponsePerEmailEnabledToggle);
|
||||
setSingleResponsePerEmailToggle(!singleResponsePerEmailToggle);
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
isSingleResponsePerEmailEnabled: !localSurvey.isSingleResponsePerEmailEnabled,
|
||||
@@ -117,6 +118,11 @@ export const ResponseOptionsCard = ({
|
||||
setLocalSurvey({ ...localSurvey, isBackButtonHidden: !localSurvey.isBackButtonHidden });
|
||||
};
|
||||
|
||||
const handleCaptureIpToggle = () => {
|
||||
setCaptureIpToggle(!captureIpToggle);
|
||||
setLocalSurvey({ ...localSurvey, isCaptureIpEnabled: !localSurvey.isCaptureIpEnabled });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!!localSurvey.surveyClosedMessage) {
|
||||
setSurveyClosedMessage({
|
||||
@@ -199,7 +205,7 @@ export const ResponseOptionsCard = ({
|
||||
)}>
|
||||
<Collapsible.CollapsibleTrigger asChild className="h-full w-full cursor-pointer">
|
||||
<div className="inline-flex px-4 py-4">
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
<div className="flex items-center pr-5 pl-2">
|
||||
<CheckIcon
|
||||
strokeWidth={3}
|
||||
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
|
||||
@@ -237,7 +243,7 @@ export const ResponseOptionsCard = ({
|
||||
value={localSurvey.autoComplete?.toString()}
|
||||
onChange={handleInputResponse}
|
||||
onBlur={handleInputResponseBlur}
|
||||
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm"
|
||||
className="mr-2 ml-2 inline w-20 bg-white text-center text-sm"
|
||||
/>
|
||||
{t("environments.surveys.edit.completed_responses")}
|
||||
</p>
|
||||
@@ -304,7 +310,7 @@ export const ResponseOptionsCard = ({
|
||||
<Input
|
||||
autoFocus
|
||||
id="heading"
|
||||
className="mb-4 mt-2 bg-white"
|
||||
className="mt-2 mb-4 bg-white"
|
||||
name="heading"
|
||||
defaultValue={surveyClosedMessage.heading}
|
||||
onChange={(e) => handleClosedSurveyMessageChange({ heading: e.target.value })}
|
||||
@@ -333,7 +339,7 @@ export const ResponseOptionsCard = ({
|
||||
<div className="m-1">
|
||||
<AdvancedOptionToggle
|
||||
htmlId="preventDoubleSubmission"
|
||||
isChecked={isSingleResponsePerEmailEnabledToggle}
|
||||
isChecked={singleResponsePerEmailToggle}
|
||||
onToggle={handleSingleResponsePerEmailToggle}
|
||||
title={t("environments.surveys.edit.prevent_double_submission")}
|
||||
description={t("environments.surveys.edit.prevent_double_submission_description")}
|
||||
@@ -380,6 +386,13 @@ export const ResponseOptionsCard = ({
|
||||
title={t("environments.surveys.edit.hide_back_button")}
|
||||
description={t("environments.surveys.edit.hide_back_button_description")}
|
||||
/>
|
||||
<AdvancedOptionToggle
|
||||
htmlId="captureIp"
|
||||
isChecked={captureIpToggle}
|
||||
onToggle={handleCaptureIpToggle}
|
||||
title={t("environments.surveys.edit.capture_ip_address")}
|
||||
description={t("environments.surveys.edit.capture_ip_address_description")}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
|
||||
@@ -108,7 +108,8 @@ export const StylingView = ({
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, setLocalSurvey]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const defaultProjectStyling = useMemo(() => {
|
||||
const { styling: projectStyling } = project;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -168,7 +168,6 @@ export const getElementTypes = (t: TFunction): TElement[] => [
|
||||
icon: MousePointerClickIcon,
|
||||
preset: {
|
||||
headline: createI18nString("", []),
|
||||
subheader: createI18nString("", []),
|
||||
ctaButtonLabel: createI18nString(t("templates.book_interview"), []),
|
||||
buttonUrl: "",
|
||||
buttonExternal: true,
|
||||
@@ -182,7 +181,6 @@ export const getElementTypes = (t: TFunction): TElement[] => [
|
||||
icon: CheckIcon,
|
||||
preset: {
|
||||
headline: createI18nString("", []),
|
||||
subheader: createI18nString("", []),
|
||||
label: createI18nString("", []),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@ export const selectSurvey = {
|
||||
autoComplete: true,
|
||||
isVerifyEmailEnabled: true,
|
||||
isSingleResponsePerEmailEnabled: true,
|
||||
isCaptureIpEnabled: true,
|
||||
redirectUrl: true,
|
||||
projectOverwrites: true,
|
||||
styling: true,
|
||||
@@ -40,6 +41,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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -48,6 +48,7 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
|
||||
redirectUrl: true,
|
||||
pin: true,
|
||||
isBackButtonHidden: true,
|
||||
isCaptureIpEnabled: true,
|
||||
|
||||
// Single use configuration
|
||||
singleUse: true,
|
||||
@@ -60,6 +61,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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -43,4 +43,5 @@ export const getMinimalSurvey = (t: TFunction): TSurvey => ({
|
||||
isBackButtonHidden: false,
|
||||
metadata: {},
|
||||
slug: null,
|
||||
isCaptureIpEnabled: false,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
+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 |
@@ -48,6 +48,169 @@ If you encounter any issues or need help setting up webhooks, feel free to reach
|
||||
|
||||
---
|
||||
|
||||
## Webhook Security with Standard Webhooks
|
||||
|
||||
Formbricks implements the [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks) specification to ensure webhook requests can be verified as genuinely originating from Formbricks.
|
||||
|
||||
### Webhook Headers
|
||||
|
||||
Every webhook request includes the following headers:
|
||||
|
||||
| Header | Description | Example |
|
||||
| ------------------- | ---------------------------------------------------- | -------------------------------------- |
|
||||
| `webhook-id` | Unique message identifier | `019ba292-c1f6-7618-aaf2-ecf8e39d1cc7` |
|
||||
| `webhook-timestamp` | Unix timestamp (seconds) when the webhook was sent | `1704547200` |
|
||||
| `webhook-signature` | HMAC-SHA256 signature (only if secret is configured) | `v1,K3Q2bXlzZWNyZXQ=` |
|
||||
|
||||
### Signing Secret
|
||||
|
||||
When you create a webhook (via the UI or API), Formbricks automatically generates a unique signing secret for that webhook. The secret follows the Standard Webhooks format: `whsec_` followed by a base64-encoded random value.
|
||||
|
||||
**Via UI:** After creating a webhook, the signing secret is displayed immediately. Copy and store it securely—you can also view it later in the webhook settings.
|
||||
|
||||
**Via API:** The signing secret is returned in the webhook creation response.
|
||||
|
||||
This secret is used to generate the HMAC signature included in each webhook request, allowing you to verify the authenticity of incoming webhooks.
|
||||
|
||||
### Signature Verification
|
||||
|
||||
The signature is computed as follows:
|
||||
|
||||
```
|
||||
signed_content = "{webhook-id}.{webhook-timestamp}.{body}"
|
||||
signature = base64(HMAC-SHA256(secret, signed_content))
|
||||
header_value = "v1,{signature}"
|
||||
```
|
||||
|
||||
### Validating Webhooks
|
||||
|
||||
To validate incoming webhook requests:
|
||||
|
||||
1. Extract the `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers
|
||||
2. Verify the timestamp is within an acceptable tolerance (e.g., 5 minutes) to prevent replay attacks
|
||||
3. Decode the secret by stripping the `whsec_` prefix and base64 decoding the rest
|
||||
4. Compute the expected signature using HMAC-SHA256 with the decoded secret
|
||||
5. Compare your computed signature with the received signature (after stripping the `v1,` prefix)
|
||||
|
||||
### Node.js Verification Functions
|
||||
|
||||
```javascript
|
||||
const crypto = require("crypto");
|
||||
|
||||
const WEBHOOK_TOLERANCE_IN_SECONDS = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
* Decodes a Formbricks webhook secret (whsec_...) to raw bytes
|
||||
*/
|
||||
function decodeSecret(secret) {
|
||||
const base64 = secret.startsWith("whsec_") ? secret.slice(6) : secret;
|
||||
return Buffer.from(base64, "base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the webhook timestamp is within tolerance
|
||||
* @throws {Error} if timestamp is too old or too new
|
||||
*/
|
||||
function verifyTimestamp(timestampHeader) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const timestamp = parseInt(timestampHeader, 10);
|
||||
|
||||
if (isNaN(timestamp)) {
|
||||
throw new Error("Invalid timestamp");
|
||||
}
|
||||
|
||||
if (Math.abs(now - timestamp) > WEBHOOK_TOLERANCE_IN_SECONDS) {
|
||||
throw new Error("Timestamp outside tolerance window");
|
||||
}
|
||||
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the expected signature for a webhook payload
|
||||
*/
|
||||
function computeSignature(webhookId, timestamp, body, secret) {
|
||||
const signedContent = `${webhookId}.${timestamp}.${body}`;
|
||||
const secretBytes = decodeSecret(secret);
|
||||
return crypto.createHmac("sha256", secretBytes).update(signedContent).digest("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a Formbricks webhook request
|
||||
* @param {string} body - Raw request body as string
|
||||
* @param {object} headers - Object containing webhook-id, webhook-timestamp, webhook-signature
|
||||
* @param {string} secret - Your webhook secret (whsec_...)
|
||||
* @returns {boolean} true if valid
|
||||
* @throws {Error} if verification fails
|
||||
*/
|
||||
function verifyWebhook(body, headers, secret) {
|
||||
const webhookId = headers["webhook-id"];
|
||||
const webhookTimestamp = headers["webhook-timestamp"];
|
||||
const webhookSignature = headers["webhook-signature"];
|
||||
|
||||
if (!webhookId || !webhookTimestamp || !webhookSignature) {
|
||||
throw new Error("Missing required webhook headers");
|
||||
}
|
||||
|
||||
// Verify timestamp
|
||||
const timestamp = verifyTimestamp(webhookTimestamp);
|
||||
|
||||
// Compute expected signature
|
||||
const expectedSignature = computeSignature(webhookId, timestamp, body, secret);
|
||||
|
||||
// Extract signature from header (format: "v1,{signature}")
|
||||
const receivedSignature = webhookSignature.split(",")[1];
|
||||
|
||||
if (!receivedSignature) {
|
||||
throw new Error("Invalid signature format");
|
||||
}
|
||||
|
||||
// Use constant-time comparison to prevent timing attacks
|
||||
const expectedBuffer = Buffer.from(expectedSignature, "utf8");
|
||||
const receivedBuffer = Buffer.from(receivedSignature, "utf8");
|
||||
|
||||
if (
|
||||
expectedBuffer.length !== receivedBuffer.length ||
|
||||
!crypto.timingSafeEqual(expectedBuffer, receivedBuffer)
|
||||
) {
|
||||
throw new Error("Invalid signature");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = { verifyWebhook, decodeSecret, computeSignature, verifyTimestamp };
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```javascript
|
||||
// In your webhook handler, use the raw body (not parsed JSON)
|
||||
try {
|
||||
verifyWebhook(rawBody, req.headers, process.env.FORMBRICKS_WEBHOOK_SECRET);
|
||||
const payload = JSON.parse(rawBody);
|
||||
// Process verified webhook...
|
||||
} catch (error) {
|
||||
// Verification failed - reject the request
|
||||
console.error("Webhook verification failed:", error.message);
|
||||
}
|
||||
```
|
||||
|
||||
<Note>
|
||||
Always use the **raw request body** (as a string) for signature verification, not the parsed JSON object.
|
||||
Parsing and re-stringifying can change the formatting and break signature validation.
|
||||
</Note>
|
||||
|
||||
### Using Standard Webhooks Libraries
|
||||
|
||||
You can also use the official [Standard Webhooks libraries](https://github.com/standard-webhooks/standard-webhooks#libraries) available for various languages:
|
||||
|
||||
- **Node.js**: `npm install standardwebhooks`
|
||||
- **Python**: `pip install standardwebhooks`
|
||||
- **Go, Ruby, Java, Kotlin, PHP, Rust**: See the [Standard Webhooks GitHub](https://github.com/standard-webhooks/standard-webhooks)
|
||||
|
||||
---
|
||||
|
||||
## Example Webhook Payloads
|
||||
|
||||
We provide the following webhook payloads, `responseCreated`, `responseUpdated`, and `responseFinished`.
|
||||
@@ -82,10 +245,10 @@ Example of Response Created webhook payload:
|
||||
},
|
||||
"singleUseId": null,
|
||||
"survey": {
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"status": "inProgress",
|
||||
"title": "Customer Satisfaction Survey",
|
||||
"type": "link",
|
||||
"status": "inProgress",
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"updatedAt": "2025-07-24T07:45:00.000Z"
|
||||
},
|
||||
"surveyId": "surveyId",
|
||||
@@ -133,10 +296,10 @@ Example of Response Updated webhook payload:
|
||||
},
|
||||
"singleUseId": null,
|
||||
"survey": {
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"status": "inProgress",
|
||||
"title": "Customer Satisfaction Survey",
|
||||
"type": "link",
|
||||
"status": "inProgress",
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"updatedAt": "2025-07-24T07:45:00.000Z"
|
||||
},
|
||||
"surveyId": "surveyId",
|
||||
@@ -185,10 +348,10 @@ Example of Response Finished webhook payload:
|
||||
},
|
||||
"singleUseId": null,
|
||||
"survey": {
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"status": "inProgress",
|
||||
"title": "Customer Satisfaction Survey",
|
||||
"type": "link",
|
||||
"status": "inProgress",
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"updatedAt": "2025-07-24T07:45:00.000Z"
|
||||
},
|
||||
"surveyId": "surveyId",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Webhook" ADD COLUMN "secret" TEXT;
|
||||
+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';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Survey" ADD COLUMN "isCaptureIpEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ model Webhook {
|
||||
environmentId String
|
||||
triggers PipelineTriggers[]
|
||||
surveyIds String[]
|
||||
secret String?
|
||||
|
||||
@@index([environmentId])
|
||||
}
|
||||
@@ -312,6 +313,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 +330,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")
|
||||
@@ -378,6 +386,7 @@ model Survey {
|
||||
isVerifyEmailEnabled Boolean @default(false)
|
||||
isSingleResponsePerEmailEnabled Boolean @default(false)
|
||||
isBackButtonHidden Boolean @default(false)
|
||||
isCaptureIpEnabled Boolean @default(false)
|
||||
pin String?
|
||||
displayPercentage Decimal?
|
||||
languages SurveyLanguage[]
|
||||
@@ -390,6 +399,9 @@ model Survey {
|
||||
|
||||
slug String? @unique
|
||||
|
||||
customHeadScripts String?
|
||||
customHeadScriptsMode SurveyScriptMode? @default(add)
|
||||
|
||||
@@index([environmentId, updatedAt])
|
||||
@@index([segmentId])
|
||||
}
|
||||
@@ -620,6 +632,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;
|
||||
@@ -39,6 +39,9 @@ export const ZWebhook = z.object({
|
||||
surveyIds: z.array(z.string().cuid2()).openapi({
|
||||
description: "The IDs of the surveys ",
|
||||
}),
|
||||
secret: z.string().nullable().openapi({
|
||||
description: "The shared secret used to generate HMAC signatures for webhook requests",
|
||||
}),
|
||||
}) satisfies z.ZodType<Webhook>;
|
||||
|
||||
ZWebhook.openapi({
|
||||
|
||||
@@ -9,7 +9,6 @@ checksums:
|
||||
common/company_logo: 82d5c0d5994508210ee02d684819f4b8
|
||||
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
||||
common/language_switch: fd72a9ada13f672f4fd5da863b22cc46
|
||||
common/less_than_x_minutes: 8a8528651d0b60dc93be451abf6a139b
|
||||
common/next: 89ddbcf710eba274963494f312bdc8a9
|
||||
common/open_in_new_tab: 6844e4922a7a40a7ee25c10ea109cdeb
|
||||
common/people_responded: b685fb877090d8658db724ad07a0dbd8
|
||||
@@ -25,12 +24,12 @@ checksums:
|
||||
common/retry: 6e44d18639560596569a1278f9c83676
|
||||
common/retrying: 0cb623dbdcbf16d3680f0180ceac734c
|
||||
common/sending_responses: 184772f70cca69424eaf34f73520789f
|
||||
common/takes: 01f96e2e84741ea8392d97ff4bd2aa52
|
||||
common/takes_less_than_x_minutes: 1208ce0d4c0a679c11c7bd209b6ccc47
|
||||
common/takes_x_minutes: 001d12366d07b406f50669e761d63e69
|
||||
common/takes_x_plus_minutes: 145b8f287de140e98f492c8db2f9fa0b
|
||||
common/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
|
||||
common/the_servers_cannot_be_reached_at_the_moment: f8adbeccac69f9230a55b5b3af52b081
|
||||
common/they_will_be_redirected_immediately: 936bc99cb575cba95ea8f04d82bb353b
|
||||
common/x_minutes: bf6ec8800c29b1447226447a991b9510
|
||||
common/x_plus_minutes: 2ef597aa029e3c71d442455fbb751991
|
||||
common/your_feedback_is_stuck: db2b6aba26723b01aee0fc918d3ca052
|
||||
errors/file_input/duplicate_files: 198dd29e67beb6abc5b2534ede7d7f68
|
||||
errors/file_input/file_size_exceeded: 072045b042a39fa1df76200f8fa36dd4
|
||||
|
||||
@@ -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}}",
|
||||
@@ -24,12 +23,12 @@
|
||||
"retry": "Retry",
|
||||
"retrying": "Retrying...",
|
||||
"sending_responses": "Sending responses...",
|
||||
"takes": "Takes",
|
||||
"takes_less_than_x_minutes": "{count, plural, one {Takes less than 1 minute} other {Takes less than {count} minutes}}",
|
||||
"takes_x_minutes": "{count, plural, one {Takes 1 minute} other {Takes {count} minutes}}",
|
||||
"takes_x_plus_minutes": "Takes {count}+ minutes",
|
||||
"terms_of_service": "Terms of Service",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "The servers cannot be reached at the moment.",
|
||||
"they_will_be_redirected_immediately": "They will be redirected immediately",
|
||||
"x_minutes": "{count, plural, one {1 minute} other {{count} minutes}}",
|
||||
"x_plus_minutes": "{count}+ minutes",
|
||||
"your_feedback_is_stuck": "Your feedback is stuck :("
|
||||
},
|
||||
"errors": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user