mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-25 09:31:37 -05:00
Compare commits
8 Commits
fix-lingo-
...
cursor/zp-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
358f821da3 | ||
|
|
78d336f8c7 | ||
|
|
95a7a265b9 | ||
|
|
136e59da68 | ||
|
|
eb0a87cf80 | ||
|
|
0dcb98ac29 | ||
|
|
540f7aaae7 | ||
|
|
2d4614a0bd |
@@ -231,4 +231,4 @@ REDIS_URL=redis://localhost:6379
|
||||
|
||||
|
||||
# Lingo.dev API key for translation generation
|
||||
LINGODOTDEV_API_KEY=your_api_key_here
|
||||
LINGO_API_KEY=your_api_key_here
|
||||
|
||||
@@ -2,21 +2,16 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
||||
import { ZSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
|
||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
|
||||
const ZGetResponsesDownloadUrlAction = z.object({
|
||||
@@ -97,68 +92,3 @@ export const getSurveyFilterDataAction = authenticatedActionClient
|
||||
|
||||
return { environmentTags: tags, attributes, meta, hiddenFields, quotas };
|
||||
});
|
||||
|
||||
/**
|
||||
* Checks if survey follow-ups are enabled for the given organization.
|
||||
*
|
||||
* @param {string} organizationId The ID of the organization to check.
|
||||
* @returns {Promise<void>} A promise that resolves if the permission is granted.
|
||||
* @throws {ResourceNotFoundError} If the organization is not found.
|
||||
* @throws {OperationNotAllowedError} If survey follow-ups are not enabled for the organization.
|
||||
*/
|
||||
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
|
||||
const organization = await getOrganization(organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("Organization not found", organizationId);
|
||||
}
|
||||
|
||||
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
|
||||
if (!isSurveyFollowUpsEnabled) {
|
||||
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
|
||||
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user?.id ?? "",
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.id),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { followUps } = parsedInput;
|
||||
|
||||
const oldSurvey = await getSurvey(parsedInput.id);
|
||||
|
||||
if (parsedInput.recaptcha?.enabled) {
|
||||
await checkSpamProtectionPermission(organizationId);
|
||||
}
|
||||
|
||||
if (followUps?.length) {
|
||||
await checkSurveyFollowUpsPermission(organizationId);
|
||||
}
|
||||
|
||||
// Context for audit log
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.id;
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.oldObject = oldSurvey;
|
||||
|
||||
const newSurvey = await updateSurvey(parsedInput);
|
||||
|
||||
ctx.auditLoggingCtx.newObject = newSurvey;
|
||||
|
||||
return newSurvey;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { updateSurveyAction } from "@/modules/survey/editor/actions";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
|
||||
import { updateSurveyAction } from "../actions";
|
||||
|
||||
interface SurveyStatusDropdownProps {
|
||||
environment: TEnvironment;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { getIsActiveCustomerAction } from "./actions";
|
||||
|
||||
interface ChatwootWidgetProps {
|
||||
chatwootBaseUrl: string;
|
||||
@@ -12,6 +13,18 @@ interface ChatwootWidgetProps {
|
||||
|
||||
const CHATWOOT_SCRIPT_ID = "chatwoot-script";
|
||||
|
||||
interface ChatwootInstance {
|
||||
setUser: (
|
||||
userId: string,
|
||||
userInfo: {
|
||||
email?: string | null;
|
||||
name?: string | null;
|
||||
}
|
||||
) => void;
|
||||
setCustomAttributes: (attributes: Record<string, unknown>) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const ChatwootWidget = ({
|
||||
userEmail,
|
||||
userName,
|
||||
@@ -20,15 +33,14 @@ export const ChatwootWidget = ({
|
||||
chatwootBaseUrl,
|
||||
}: ChatwootWidgetProps) => {
|
||||
const userSetRef = useRef(false);
|
||||
const customerStatusSetRef = useRef(false);
|
||||
|
||||
const getChatwoot = useCallback((): ChatwootInstance | null => {
|
||||
return (globalThis as unknown as { $chatwoot: ChatwootInstance }).$chatwoot ?? null;
|
||||
}, []);
|
||||
|
||||
const setUserInfo = useCallback(() => {
|
||||
const $chatwoot = (
|
||||
globalThis as unknown as {
|
||||
$chatwoot: {
|
||||
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
|
||||
};
|
||||
}
|
||||
).$chatwoot;
|
||||
const $chatwoot = getChatwoot();
|
||||
if (userId && $chatwoot && !userSetRef.current) {
|
||||
$chatwoot.setUser(userId, {
|
||||
email: userEmail,
|
||||
@@ -36,7 +48,19 @@ export const ChatwootWidget = ({
|
||||
});
|
||||
userSetRef.current = true;
|
||||
}
|
||||
}, [userId, userEmail, userName]);
|
||||
}, [userId, userEmail, userName, getChatwoot]);
|
||||
|
||||
const setCustomerStatus = useCallback(async () => {
|
||||
if (customerStatusSetRef.current) return;
|
||||
const $chatwoot = getChatwoot();
|
||||
if (!$chatwoot) return;
|
||||
|
||||
const response = await getIsActiveCustomerAction();
|
||||
if (response?.data !== undefined) {
|
||||
$chatwoot.setCustomAttributes({ isActiveCustomer: response.data });
|
||||
}
|
||||
customerStatusSetRef.current = true;
|
||||
}, [getChatwoot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatwootWebsiteToken) return;
|
||||
@@ -65,23 +89,19 @@ export const ChatwootWidget = ({
|
||||
const handleChatwootReady = () => setUserInfo();
|
||||
globalThis.addEventListener("chatwoot:ready", handleChatwootReady);
|
||||
|
||||
const handleChatwootOpen = () => setCustomerStatus();
|
||||
globalThis.addEventListener("chatwoot:open", handleChatwootOpen);
|
||||
|
||||
// Check if Chatwoot is already ready
|
||||
if (
|
||||
(
|
||||
globalThis as unknown as {
|
||||
$chatwoot: {
|
||||
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
|
||||
};
|
||||
}
|
||||
).$chatwoot
|
||||
) {
|
||||
if (getChatwoot()) {
|
||||
setUserInfo();
|
||||
}
|
||||
|
||||
return () => {
|
||||
globalThis.removeEventListener("chatwoot:ready", handleChatwootReady);
|
||||
globalThis.removeEventListener("chatwoot:open", handleChatwootOpen);
|
||||
|
||||
const $chatwoot = (globalThis as unknown as { $chatwoot: { reset: () => void } }).$chatwoot;
|
||||
const $chatwoot = getChatwoot();
|
||||
if ($chatwoot) {
|
||||
$chatwoot.reset();
|
||||
}
|
||||
@@ -90,8 +110,18 @@ export const ChatwootWidget = ({
|
||||
scriptElement?.remove();
|
||||
|
||||
userSetRef.current = false;
|
||||
customerStatusSetRef.current = false;
|
||||
};
|
||||
}, [chatwootBaseUrl, chatwootWebsiteToken, userId, userEmail, userName, setUserInfo]);
|
||||
}, [
|
||||
chatwootBaseUrl,
|
||||
chatwootWebsiteToken,
|
||||
userId,
|
||||
userEmail,
|
||||
userName,
|
||||
setUserInfo,
|
||||
setCustomerStatus,
|
||||
getChatwoot,
|
||||
]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
18
apps/web/app/chatwoot/actions.ts
Normal file
18
apps/web/app/chatwoot/actions.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
"use server";
|
||||
|
||||
import { TCloudBillingPlan } from "@formbricks/types/organizations";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
|
||||
export const getIsActiveCustomerAction = authenticatedActionClient.action(async ({ ctx }) => {
|
||||
const paidBillingPlans = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
|
||||
|
||||
const organizations = await getOrganizationsByUserId(ctx.user.id);
|
||||
return organizations.some((organization) => {
|
||||
const stripe = organization.billing.stripe;
|
||||
const isPaidPlan = stripe?.plan ? paidBillingPlans.has(stripe.plan) : false;
|
||||
const isActiveSubscription =
|
||||
stripe?.subscriptionStatus === "active" || stripe?.subscriptionStatus === "trialing";
|
||||
return isPaidPlan && isActiveSubscription;
|
||||
});
|
||||
});
|
||||
@@ -808,6 +808,7 @@ checksums:
|
||||
environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6
|
||||
environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027
|
||||
environments/integrations/webhooks/learn_to_verify: 25b2a035e2109170b28f4e16db76ad39
|
||||
environments/integrations/webhooks/no_triggers: 6b68cddfc45b3f7e20644a24a1bbea69
|
||||
environments/integrations/webhooks/please_check_console: 7b1787e82a0d762df02c011ebb1650ea
|
||||
environments/integrations/webhooks/please_enter_a_url: c24c74d0ce7ed3a6b858aadbc82108fe
|
||||
environments/integrations/webhooks/response_created: 8c43b1b6d748f6096f6f8d9232a3c469
|
||||
@@ -1632,13 +1633,13 @@ checksums:
|
||||
environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e
|
||||
environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0
|
||||
environments/surveys/edit/show_to_x_percentage_of_targeted_users: b745169011fa7e8ca475baa5500c5197
|
||||
environments/surveys/edit/shrink_preview: 42567389520b226f211f94f052197ad8
|
||||
environments/surveys/edit/simple: 65575bd903091299bc4a94b7517a6288
|
||||
environments/surveys/edit/six_points: c6c09b3f07171dc388cb5a610ea79af7
|
||||
environments/surveys/edit/smiley: e68e3b28fc3c04255e236c6a0feb662b
|
||||
environments/surveys/edit/spam_protection_note: 94059310d07c30f6704e216297036d05
|
||||
environments/surveys/edit/spam_protection_threshold_description: ed8b8c9c583077a88bf5dd3ec8b59e60
|
||||
environments/surveys/edit/spam_protection_threshold_heading: 29f9a8b00c5bcbb43aedc48138a5cf9c
|
||||
environments/surveys/edit/shrink_preview: 42567389520b226f211f94f052197ad8
|
||||
environments/surveys/edit/star: 0586c1c76e8a0367c0a7b93adf598cb7
|
||||
environments/surveys/edit/starts_with: f6673c17475708313c6a0f245b561781
|
||||
environments/surveys/edit/state: 118de561d4525b14f9bb29ac9e86161d
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { type Instrumentation } from "next";
|
||||
import { isExpectedError } from "@formbricks/types/errors";
|
||||
import { IS_PRODUCTION, PROMETHEUS_ENABLED, SENTRY_DSN } from "@/lib/constants";
|
||||
|
||||
export const onRequestError = Sentry.captureRequestError;
|
||||
export const onRequestError: Instrumentation.onRequestError = (...args) => {
|
||||
const [error] = args;
|
||||
|
||||
// Skip expected business-logic errors (AuthorizationError, ResourceNotFoundError, etc.)
|
||||
// These are handled gracefully in the UI and don't need server-side Sentry reporting
|
||||
if (error instanceof Error && isExpectedError(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.captureRequestError(...args);
|
||||
};
|
||||
|
||||
export const register = async () => {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
|
||||
@@ -852,9 +852,16 @@
|
||||
"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_bad_gateway_error": "Ungültiges Gateway (502): Proxy-/Gateway-Fehler, Dienst nicht erreichbar",
|
||||
"endpoint_gateway_timeout_error": "Gateway-Zeitüberschreitung (504): Gateway-Zeitüberschreitung, Dienst nicht erreichbar",
|
||||
"endpoint_internal_server_error": "Interner Serverfehler (500): Der Dienst ist auf einen unerwarteten Fehler gestoßen",
|
||||
"endpoint_method_not_allowed_error": "Methode nicht erlaubt (405): Der Endpoint existiert, akzeptiert aber keine POST-Anfragen",
|
||||
"endpoint_not_found_error": "Nicht gefunden (404): Der Endpoint existiert nicht",
|
||||
"endpoint_pinged": "Juhu! Wir können den Webhook anpingen!",
|
||||
"endpoint_pinged_error": "Kann den Webhook nicht anpingen!",
|
||||
"endpoint_service_unavailable_error": "Dienst nicht verfügbar (503): Dienst ist vorübergehend nicht verfügbar",
|
||||
"learn_to_verify": "Erfahren Sie, wie Sie Webhook-Signaturen verifizieren",
|
||||
"no_triggers": "Keine Trigger",
|
||||
"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",
|
||||
@@ -1705,13 +1712,13 @@
|
||||
"show_survey_maximum_of": "Umfrage maximal anzeigen von",
|
||||
"show_survey_to_users": "Umfrage % der Nutzer anzeigen",
|
||||
"show_to_x_percentage_of_targeted_users": "Zeige {percentage}% der Zielbenutzer",
|
||||
"shrink_preview": "Vorschau verkleinern",
|
||||
"simple": "Einfach",
|
||||
"six_points": "6 Punkte",
|
||||
"smiley": "Smiley",
|
||||
"spam_protection_note": "Spamschutz funktioniert nicht für Umfragen, die mit den iOS-, React Native- und Android-SDKs angezeigt werden. Es wird die Umfrage unterbrechen.",
|
||||
"spam_protection_threshold_description": "Wert zwischen 0 und 1 festlegen, Antworten unter diesem Wert werden abgelehnt.",
|
||||
"spam_protection_threshold_heading": "Antwortschwelle",
|
||||
"shrink_preview": "Vorschau verkleinern",
|
||||
"star": "Stern",
|
||||
"starts_with": "Fängt an mit",
|
||||
"state": "Bundesland",
|
||||
|
||||
@@ -852,9 +852,16 @@
|
||||
"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_bad_gateway_error": "Bad Gateway (502): Proxy/gateway error, service not reachable",
|
||||
"endpoint_gateway_timeout_error": "Gateway Timeout (504): Gateway timeout, service not reachable",
|
||||
"endpoint_internal_server_error": "Internal Server Error (500): The service encountered an unexpected error",
|
||||
"endpoint_method_not_allowed_error": "Method Not Allowed (405): The endpoint exists, but doesn't accept POST requests",
|
||||
"endpoint_not_found_error": "Not Found (404): The endpoint doesn't exist",
|
||||
"endpoint_pinged": "Yay! We are able to ping the webhook!",
|
||||
"endpoint_pinged_error": "Unable to ping the webhook!",
|
||||
"endpoint_service_unavailable_error": "Service Unavailable (503): Service is temporarily down",
|
||||
"learn_to_verify": "Learn how to verify webhook signatures",
|
||||
"no_triggers": "No Triggers",
|
||||
"please_check_console": "Please check the console for more details",
|
||||
"please_enter_a_url": "Please enter a URL",
|
||||
"response_created": "Response Created",
|
||||
@@ -1705,13 +1712,13 @@
|
||||
"show_survey_maximum_of": "Show survey maximum of",
|
||||
"show_survey_to_users": "Show survey to % of users",
|
||||
"show_to_x_percentage_of_targeted_users": "Show to {percentage}% of targeted users",
|
||||
"shrink_preview": "Shrink Preview",
|
||||
"simple": "Simple",
|
||||
"six_points": "6 points",
|
||||
"smiley": "Smiley",
|
||||
"spam_protection_note": "Spam protection does not work for surveys displayed with the iOS, React Native, and Android SDKs. It will break the survey.",
|
||||
"spam_protection_threshold_description": "Set value between 0 and 1, responses below this value will be rejected.",
|
||||
"spam_protection_threshold_heading": "Response threshold",
|
||||
"shrink_preview": "Shrink Preview",
|
||||
"star": "Star",
|
||||
"starts_with": "Starts with",
|
||||
"state": "State",
|
||||
|
||||
@@ -852,9 +852,16 @@
|
||||
"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_bad_gateway_error": "Puerta de enlace incorrecta (502): Error de proxy o puerta de enlace, servicio no accesible",
|
||||
"endpoint_gateway_timeout_error": "Tiempo de espera de la puerta de enlace agotado (504): Tiempo de espera de la puerta de enlace agotado, servicio no accesible",
|
||||
"endpoint_internal_server_error": "Error interno del servidor (500): El servicio encontró un error inesperado",
|
||||
"endpoint_method_not_allowed_error": "Método no permitido (405): El endpoint existe, pero no acepta solicitudes POST",
|
||||
"endpoint_not_found_error": "No encontrado (404): El endpoint no existe",
|
||||
"endpoint_pinged": "¡Genial! ¡Podemos hacer ping al webhook!",
|
||||
"endpoint_pinged_error": "¡No se puede hacer ping al webhook!",
|
||||
"endpoint_service_unavailable_error": "Servicio no disponible (503): El servicio está temporalmente caído",
|
||||
"learn_to_verify": "Aprende a verificar las firmas de webhook",
|
||||
"no_triggers": "Sin activadores",
|
||||
"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",
|
||||
@@ -1705,13 +1712,13 @@
|
||||
"show_survey_maximum_of": "Mostrar encuesta un máximo de",
|
||||
"show_survey_to_users": "Mostrar encuesta al % de usuarios",
|
||||
"show_to_x_percentage_of_targeted_users": "Mostrar al {percentage} % de usuarios objetivo",
|
||||
"shrink_preview": "Contraer vista previa",
|
||||
"simple": "Simple",
|
||||
"six_points": "6 puntos",
|
||||
"smiley": "Emoticono",
|
||||
"spam_protection_note": "La protección contra spam no funciona para encuestas mostradas con los SDK de iOS, React Native y Android. Romperá la encuesta.",
|
||||
"spam_protection_threshold_description": "Establece un valor entre 0 y 1, las respuestas por debajo de este valor serán rechazadas.",
|
||||
"spam_protection_threshold_heading": "Umbral de respuesta",
|
||||
"shrink_preview": "Contraer vista previa",
|
||||
"star": "Estrella",
|
||||
"starts_with": "Comienza con",
|
||||
"state": "Estado",
|
||||
|
||||
@@ -852,9 +852,16 @@
|
||||
"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_bad_gateway_error": "Mauvaise passerelle (502) : Erreur de proxy/passerelle, service inaccessible",
|
||||
"endpoint_gateway_timeout_error": "Délai d'attente de la passerelle dépassé (504) : Le délai d'attente de la passerelle a expiré, service inaccessible",
|
||||
"endpoint_internal_server_error": "Erreur interne du serveur (500) : Le service a rencontré une erreur inattendue",
|
||||
"endpoint_method_not_allowed_error": "Méthode non autorisée (405) : Le point de terminaison existe, mais n'accepte pas les requêtes POST",
|
||||
"endpoint_not_found_error": "Introuvable (404) : Le point de terminaison n'existe pas",
|
||||
"endpoint_pinged": "Yay ! Nous pouvons pinger le webhook !",
|
||||
"endpoint_pinged_error": "Impossible de pinger le webhook !",
|
||||
"endpoint_service_unavailable_error": "Service indisponible (503) : Le service est temporairement indisponible",
|
||||
"learn_to_verify": "Découvrez comment vérifier les signatures de webhook",
|
||||
"no_triggers": "Aucun déclencheur",
|
||||
"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",
|
||||
@@ -1705,13 +1712,13 @@
|
||||
"show_survey_maximum_of": "Afficher le maximum du sondage de",
|
||||
"show_survey_to_users": "Afficher l'enquête à % des utilisateurs",
|
||||
"show_to_x_percentage_of_targeted_users": "Afficher à {percentage}% des utilisateurs ciblés",
|
||||
"shrink_preview": "Réduire l'aperçu",
|
||||
"simple": "Simple",
|
||||
"six_points": "6 points",
|
||||
"smiley": "Sourire",
|
||||
"spam_protection_note": "La protection contre le spam ne fonctionne pas pour les enquêtes affichées avec les SDK iOS, React Native et Android. Cela cassera l'enquête.",
|
||||
"spam_protection_threshold_description": "Définir une valeur entre 0 et 1, les réponses en dessous de cette valeur seront rejetées.",
|
||||
"spam_protection_threshold_heading": "Seuil de réponse",
|
||||
"shrink_preview": "Réduire l'aperçu",
|
||||
"star": "Étoile",
|
||||
"starts_with": "Commence par",
|
||||
"state": "État",
|
||||
|
||||
@@ -852,9 +852,16 @@
|
||||
"created_by_third_party": "Harmadik fél által létrehozva",
|
||||
"discord_webhook_not_supported": "A Discord webhorgok jelenleg nem támogatottak.",
|
||||
"empty_webhook_message": "A webhorgai itt fognak megjelenni, amint hozzáadja azokat. ⏲️",
|
||||
"endpoint_bad_gateway_error": "Hibás átjáró (502): Proxy-/átjáróhiba, a szolgáltatás nem érhető el",
|
||||
"endpoint_gateway_timeout_error": "Átjáró időtúllépés (504): Átjáró időtúllépés, a szolgáltatás nem érhető el",
|
||||
"endpoint_internal_server_error": "Belső szerverhiba (500): A szolgáltatás váratlan hibába ütközött",
|
||||
"endpoint_method_not_allowed_error": "A metódus nem engedélyezett (405): A végpont létezik, de nem fogad POST kéréseket",
|
||||
"endpoint_not_found_error": "Nem található (404): A végpont nem létezik",
|
||||
"endpoint_pinged": "Hurrá! Képesek vagyunk pingelni a webhorgot!",
|
||||
"endpoint_pinged_error": "Nem lehet pingelni a webhorgot!",
|
||||
"endpoint_service_unavailable_error": "A szolgáltatás nem érhető el (503): A szolgáltatás átmenetileg nem elérhető",
|
||||
"learn_to_verify": "Tudja meg, hogy kell ellenőrizni a webhorog aláírásait",
|
||||
"no_triggers": "Nincsenek Triggerek",
|
||||
"please_check_console": "További részletekért nézze meg a konzolt",
|
||||
"please_enter_a_url": "Adjon meg egy URL-t",
|
||||
"response_created": "Válasz létrehozva",
|
||||
@@ -1705,13 +1712,13 @@
|
||||
"show_survey_maximum_of": "Kérdőív megjelenítése legfeljebb:",
|
||||
"show_survey_to_users": "Kérdőív megjelenítése a felhasználók ennyi százalékának",
|
||||
"show_to_x_percentage_of_targeted_users": "Megjelenítés a célzott felhasználók {percentage}%-ának",
|
||||
"shrink_preview": "Előnézet összecsukása",
|
||||
"simple": "Egyszerű",
|
||||
"six_points": "6 pont",
|
||||
"smiley": "Hangulatjel",
|
||||
"spam_protection_note": "A szemét elleni védekezés nem működik az iOS, React Native és Android SDK-kkal megjelenített kérdőíveknél. El fogja rontani a kérdőívet.",
|
||||
"spam_protection_threshold_description": "Állítsa az értéket 0 és 1 közé, az ezen érték alatt lévő válaszok elutasításra kerülnek.",
|
||||
"spam_protection_threshold_heading": "Válasz küszöbszintje",
|
||||
"shrink_preview": "Előnézet összecsukása",
|
||||
"star": "Csillag",
|
||||
"starts_with": "Ezzel kezdődik",
|
||||
"state": "Állapot",
|
||||
|
||||
@@ -852,9 +852,16 @@
|
||||
"created_by_third_party": "サードパーティによって作成",
|
||||
"discord_webhook_not_supported": "現在、Discord Webhook はサポートしていません。",
|
||||
"empty_webhook_message": "Webhook は追加するとここに表示されます。⏲️",
|
||||
"endpoint_bad_gateway_error": "不正なゲートウェイ (502): プロキシまたはゲートウェイのエラーにより、サービスに到達できません",
|
||||
"endpoint_gateway_timeout_error": "ゲートウェイタイムアウト (504): ゲートウェイのタイムアウトにより、サービスに到達できません",
|
||||
"endpoint_internal_server_error": "内部サーバーエラー (500): サービスで予期しないエラーが発生しました",
|
||||
"endpoint_method_not_allowed_error": "許可されていないメソッド (405): エンドポイントは存在しますが、POST リクエストを受け付けません",
|
||||
"endpoint_not_found_error": "見つかりません (404): エンドポイントが存在しません",
|
||||
"endpoint_pinged": "成功!Webhook に ping できました。",
|
||||
"endpoint_pinged_error": "Webhook への ping に失敗しました。",
|
||||
"endpoint_service_unavailable_error": "サービス利用不可 (503): サービスは一時的に停止しています",
|
||||
"learn_to_verify": "Webhook署名の検証方法を学ぶ",
|
||||
"no_triggers": "トリガーなし",
|
||||
"please_check_console": "詳細はコンソールを確認してください",
|
||||
"please_enter_a_url": "URL を入力してください",
|
||||
"response_created": "回答作成",
|
||||
@@ -1705,13 +1712,13 @@
|
||||
"show_survey_maximum_of": "フォームの最大表示回数",
|
||||
"show_survey_to_users": "ユーザーの {percentage}% にフォームを表示",
|
||||
"show_to_x_percentage_of_targeted_users": "ターゲットユーザーの {percentage}% に表示",
|
||||
"shrink_preview": "プレビューを縮小",
|
||||
"simple": "シンプル",
|
||||
"six_points": "6点",
|
||||
"smiley": "スマイリー",
|
||||
"spam_protection_note": "スパム対策は、iOS、React Native、およびAndroid SDKで表示されるフォームでは機能しません。フォームが壊れます。",
|
||||
"spam_protection_threshold_description": "値を0から1の間で設定してください。この値より低い回答は拒否されます。",
|
||||
"spam_protection_threshold_heading": "回答のしきい値",
|
||||
"shrink_preview": "プレビューを縮小",
|
||||
"star": "星",
|
||||
"starts_with": "で始まる",
|
||||
"state": "都道府県",
|
||||
|
||||
@@ -852,9 +852,16 @@
|
||||
"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_bad_gateway_error": "Ongeldige gateway (502): Proxy-/gatewayfout, service niet bereikbaar",
|
||||
"endpoint_gateway_timeout_error": "Gateway-time-out (504): Gateway-time-out, service niet bereikbaar",
|
||||
"endpoint_internal_server_error": "Interne serverfout (500): De service is een onverwachte fout tegengekomen",
|
||||
"endpoint_method_not_allowed_error": "Methode niet toegestaan (405): Het endpoint bestaat, maar accepteert geen POST-verzoeken",
|
||||
"endpoint_not_found_error": "Niet gevonden (404): Het endpoint bestaat niet",
|
||||
"endpoint_pinged": "Jawel! We kunnen de webhook pingen!",
|
||||
"endpoint_pinged_error": "Kan de webhook niet pingen!",
|
||||
"endpoint_service_unavailable_error": "Service niet beschikbaar (503): De service is tijdelijk niet beschikbaar",
|
||||
"learn_to_verify": "Leer hoe je webhook-handtekeningen kunt verifiëren",
|
||||
"no_triggers": "Geen triggers",
|
||||
"please_check_console": "Controleer de console voor meer details",
|
||||
"please_enter_a_url": "Voer een URL in",
|
||||
"response_created": "Reactie gemaakt",
|
||||
@@ -1705,13 +1712,13 @@
|
||||
"show_survey_maximum_of": "Toon onderzoek maximaal",
|
||||
"show_survey_to_users": "Enquête tonen aan % van de gebruikers",
|
||||
"show_to_x_percentage_of_targeted_users": "Toon aan {percentage}% van de getargete gebruikers",
|
||||
"shrink_preview": "Voorbeeld invouwen",
|
||||
"simple": "Eenvoudig",
|
||||
"six_points": "6 punten",
|
||||
"smiley": "Smiley",
|
||||
"spam_protection_note": "Spambeveiliging werkt niet voor enquêtes die worden weergegeven met de iOS-, React Native- en Android SDK's. Het zal de enquête breken.",
|
||||
"spam_protection_threshold_description": "Stel een waarde in tussen 0 en 1, reacties onder deze waarde worden afgewezen.",
|
||||
"spam_protection_threshold_heading": "Reactiedrempel",
|
||||
"shrink_preview": "Voorbeeld invouwen",
|
||||
"star": "Ster",
|
||||
"starts_with": "Begint met",
|
||||
"state": "Staat",
|
||||
|
||||
@@ -852,9 +852,16 @@
|
||||
"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_bad_gateway_error": "Gateway inválido (502): Erro de proxy/gateway, serviço inacessível",
|
||||
"endpoint_gateway_timeout_error": "Tempo limite do gateway esgotado (504): Tempo limite do gateway esgotado, serviço inacessível",
|
||||
"endpoint_internal_server_error": "Erro interno do servidor (500): O serviço encontrou um erro inesperado",
|
||||
"endpoint_method_not_allowed_error": "Método não permitido (405): O endpoint existe, mas não aceita solicitações POST",
|
||||
"endpoint_not_found_error": "Não encontrado (404): O endpoint não existe",
|
||||
"endpoint_pinged": "Uhul! Conseguimos pingar o webhook!",
|
||||
"endpoint_pinged_error": "Não consegui pingar o webhook!",
|
||||
"endpoint_service_unavailable_error": "Serviço indisponível (503): O serviço está temporariamente indisponível",
|
||||
"learn_to_verify": "Aprenda como verificar assinaturas de webhook",
|
||||
"no_triggers": "Nenhum Gatilho",
|
||||
"please_check_console": "Por favor, verifica o console para mais detalhes",
|
||||
"please_enter_a_url": "Por favor, insira uma URL",
|
||||
"response_created": "Resposta Criada",
|
||||
@@ -1705,13 +1712,13 @@
|
||||
"show_survey_maximum_of": "Mostrar no máximo",
|
||||
"show_survey_to_users": "Mostrar pesquisa para % dos usuários",
|
||||
"show_to_x_percentage_of_targeted_users": "Mostrar para {percentage}% dos usuários segmentados",
|
||||
"shrink_preview": "Recolher prévia",
|
||||
"simple": "Simples",
|
||||
"six_points": "6 pontos",
|
||||
"smiley": "Sorridente",
|
||||
"spam_protection_note": "A proteção contra spam não funciona para pesquisas exibidas com os SDKs iOS, React Native e Android. Isso vai quebrar a pesquisa.",
|
||||
"spam_protection_threshold_description": "Defina um valor entre 0 e 1, respostas abaixo desse valor serão rejeitadas.",
|
||||
"spam_protection_threshold_heading": "Limite de resposta",
|
||||
"shrink_preview": "Recolher prévia",
|
||||
"star": "Estrela",
|
||||
"starts_with": "Começa com",
|
||||
"state": "Estado",
|
||||
|
||||
@@ -852,9 +852,16 @@
|
||||
"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_bad_gateway_error": "Gateway inválido (502): Erro de proxy/gateway, serviço inacessível",
|
||||
"endpoint_gateway_timeout_error": "Tempo limite do gateway excedido (504): Tempo limite do gateway excedido, serviço inacessível",
|
||||
"endpoint_internal_server_error": "Erro interno do servidor (500): O serviço encontrou um erro inesperado",
|
||||
"endpoint_method_not_allowed_error": "Método não permitido (405): O endpoint existe, mas não aceita pedidos POST",
|
||||
"endpoint_not_found_error": "Não encontrado (404): O endpoint não existe",
|
||||
"endpoint_pinged": "Yay! Conseguimos aceder ao webhook!",
|
||||
"endpoint_pinged_error": "Não foi possível aceder ao webhook!",
|
||||
"endpoint_service_unavailable_error": "Serviço indisponível (503): O serviço está temporariamente indisponível",
|
||||
"learn_to_verify": "Aprenda a verificar assinaturas de webhook",
|
||||
"no_triggers": "Sem Acionadores",
|
||||
"please_check_console": "Por favor, verifique a consola para mais detalhes",
|
||||
"please_enter_a_url": "Por favor, insira um URL",
|
||||
"response_created": "Resposta Criada",
|
||||
@@ -1705,13 +1712,13 @@
|
||||
"show_survey_maximum_of": "Mostrar inquérito máximo de",
|
||||
"show_survey_to_users": "Mostrar inquérito a % dos utilizadores",
|
||||
"show_to_x_percentage_of_targeted_users": "Mostrar a {percentage}% dos utilizadores alvo",
|
||||
"shrink_preview": "Reduzir pré-visualização",
|
||||
"simple": "Simples",
|
||||
"six_points": "6 pontos",
|
||||
"smiley": "Sorridente",
|
||||
"spam_protection_note": "A proteção contra spam não funciona para inquéritos exibidos com os SDKs iOS, React Native e Android. Isso irá quebrar o inquérito.",
|
||||
"spam_protection_threshold_description": "Defina um valor entre 0 e 1, respostas abaixo deste valor serão rejeitadas.",
|
||||
"spam_protection_threshold_heading": "Limite de resposta",
|
||||
"shrink_preview": "Reduzir pré-visualização",
|
||||
"star": "Estrela",
|
||||
"starts_with": "Começa com",
|
||||
"state": "Estado",
|
||||
|
||||
@@ -852,9 +852,16 @@
|
||||
"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_bad_gateway_error": "Gateway invalid (502): Eroare de proxy/gateway, serviciul nu este accesibil",
|
||||
"endpoint_gateway_timeout_error": "Timp de așteptare gateway depășit (504): Timpul de așteptare al gateway-ului a fost depășit, serviciul nu este accesibil",
|
||||
"endpoint_internal_server_error": "Eroare internă de server (500): Serviciul a întâmpinat o eroare neașteptată",
|
||||
"endpoint_method_not_allowed_error": "Metodă nepermisă (405): Endpointul există, dar nu acceptă cereri POST",
|
||||
"endpoint_not_found_error": "Negăsit (404): Endpointul nu există",
|
||||
"endpoint_pinged": "Grozav! Am reușit să ping-ui webhooks-ul!",
|
||||
"endpoint_pinged_error": "Nu pot să ping-ui webhooks-ul!",
|
||||
"endpoint_service_unavailable_error": "Serviciu indisponibil (503): Serviciul este temporar indisponibil",
|
||||
"learn_to_verify": "Află cum să verifici semnăturile webhook",
|
||||
"no_triggers": "Fără declanșatori",
|
||||
"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",
|
||||
@@ -1705,13 +1712,13 @@
|
||||
"show_survey_maximum_of": "Afișează sondajul de maxim",
|
||||
"show_survey_to_users": "Afișați sondajul la % din utilizatori",
|
||||
"show_to_x_percentage_of_targeted_users": "Afișați la {percentage}% din utilizatorii vizați",
|
||||
"shrink_preview": "Restrânge previzualizarea",
|
||||
"simple": "Simplu",
|
||||
"six_points": "6 puncte",
|
||||
"smiley": "Smiley",
|
||||
"spam_protection_note": "Protecția împotriva spamului nu funcționează pentru sondajele afișate folosind SDK-urile iOS, React Native și Android. Va întrerupe sondajul.",
|
||||
"spam_protection_threshold_description": "Setați valoarea între 0 și 1, răspunsurile sub această valoare vor fi respinse.",
|
||||
"spam_protection_threshold_heading": "Pragul răspunsurilor",
|
||||
"shrink_preview": "Restrânge previzualizarea",
|
||||
"star": "Stea",
|
||||
"starts_with": "Începe cu",
|
||||
"state": "Stare",
|
||||
|
||||
@@ -852,9 +852,16 @@
|
||||
"created_by_third_party": "Создано сторонней организацией",
|
||||
"discord_webhook_not_supported": "В настоящее время webhooks Discord не поддерживаются.",
|
||||
"empty_webhook_message": "Ваши webhooks появятся здесь, как только вы их добавите. ⏲️",
|
||||
"endpoint_bad_gateway_error": "Ошибка шлюза (502): Ошибка прокси/шлюза, сервис недоступен",
|
||||
"endpoint_gateway_timeout_error": "Тайм-аут шлюза (504): Тайм-аут шлюза, сервис недоступен",
|
||||
"endpoint_internal_server_error": "Внутренняя ошибка сервера (500): Сервис столкнулся с непредвиденной ошибкой",
|
||||
"endpoint_method_not_allowed_error": "Метод не разрешен (405): Конечная точка существует, но не принимает POST-запросы",
|
||||
"endpoint_not_found_error": "Не найдено (404): Конечная точка не существует",
|
||||
"endpoint_pinged": "Ура! Нам удалось отправить ping на webhook!",
|
||||
"endpoint_pinged_error": "Не удалось отправить ping на webhook!",
|
||||
"endpoint_service_unavailable_error": "Сервис недоступен (503): Сервис временно недоступен",
|
||||
"learn_to_verify": "Узнайте, как проверить подписи вебхуков",
|
||||
"no_triggers": "Нет триггеров",
|
||||
"please_check_console": "Пожалуйста, проверьте консоль для получения подробностей",
|
||||
"please_enter_a_url": "Пожалуйста, введите URL",
|
||||
"response_created": "Ответ создан",
|
||||
@@ -1705,13 +1712,13 @@
|
||||
"show_survey_maximum_of": "Показать опрос максимум",
|
||||
"show_survey_to_users": "Показать опрос % пользователей",
|
||||
"show_to_x_percentage_of_targeted_users": "Показать {percentage}% целевых пользователей",
|
||||
"shrink_preview": "Свернуть предпросмотр",
|
||||
"simple": "Простой",
|
||||
"six_points": "6 баллов",
|
||||
"smiley": "Смайлик",
|
||||
"spam_protection_note": "Защита от спама не работает для опросов, отображаемых с помощью SDK iOS, React Native и Android. Это приведёт к сбою опроса.",
|
||||
"spam_protection_threshold_description": "Установите значение от 0 до 1, ответы ниже этого значения будут отклонены.",
|
||||
"spam_protection_threshold_heading": "Порог ответа",
|
||||
"shrink_preview": "Свернуть предпросмотр",
|
||||
"star": "Звезда",
|
||||
"starts_with": "Начинается с",
|
||||
"state": "Состояние",
|
||||
|
||||
@@ -852,9 +852,16 @@
|
||||
"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_bad_gateway_error": "Felaktig gateway (502): Proxy-/gatewayfel, tjänsten kan inte nås",
|
||||
"endpoint_gateway_timeout_error": "Gateway-timeout (504): Gateway-timeout, tjänsten kan inte nås",
|
||||
"endpoint_internal_server_error": "Internt serverfel (500): Tjänsten stötte på ett oväntat fel",
|
||||
"endpoint_method_not_allowed_error": "Metoden tillåts inte (405): Endpointen finns, men accepterar inte POST-förfrågningar",
|
||||
"endpoint_not_found_error": "Hittades inte (404): Endpointen finns inte",
|
||||
"endpoint_pinged": "Ja! Vi kan nå webhooken!",
|
||||
"endpoint_pinged_error": "Kunde inte nå webhooken!",
|
||||
"endpoint_service_unavailable_error": "Tjänsten är inte tillgänglig (503): Tjänsten är tillfälligt nere",
|
||||
"learn_to_verify": "Lär dig hur du verifierar webhook-signaturer",
|
||||
"no_triggers": "Inga utlösare",
|
||||
"please_check_console": "Vänligen kontrollera konsolen för mer information",
|
||||
"please_enter_a_url": "Vänligen ange en URL",
|
||||
"response_created": "Svar skapat",
|
||||
@@ -1705,13 +1712,13 @@
|
||||
"show_survey_maximum_of": "Visa enkät maximalt",
|
||||
"show_survey_to_users": "Visa enkät för % av användare",
|
||||
"show_to_x_percentage_of_targeted_users": "Visa för {percentage}% av målgruppens användare",
|
||||
"shrink_preview": "Minimera förhandsgranskning",
|
||||
"simple": "Enkel",
|
||||
"six_points": "6 poäng",
|
||||
"smiley": "Smiley",
|
||||
"spam_protection_note": "Spamskydd fungerar inte för enkäter som visas med iOS, React Native och Android SDK:er. Det kommer att bryta enkäten.",
|
||||
"spam_protection_threshold_description": "Ställ in värde mellan 0 och 1, svar under detta värde kommer att avvisas.",
|
||||
"spam_protection_threshold_heading": "Svarströskel",
|
||||
"shrink_preview": "Minimera förhandsgranskning",
|
||||
"star": "Stjärna",
|
||||
"starts_with": "Börjar med",
|
||||
"state": "Delstat",
|
||||
|
||||
@@ -852,9 +852,16 @@
|
||||
"created_by_third_party": "由 第三方 创建",
|
||||
"discord_webhook_not_supported": "Discord webhooks 目前不 支持。",
|
||||
"empty_webhook_message": "您的 Webhooks 会在您 添加 后 出现在这里。 ⏲️",
|
||||
"endpoint_bad_gateway_error": "错误网关 (502):代理/网关错误,服务不可达",
|
||||
"endpoint_gateway_timeout_error": "网关超时 (504):网关超时,服务不可达",
|
||||
"endpoint_internal_server_error": "内部服务器错误 (500):服务遇到了意外错误",
|
||||
"endpoint_method_not_allowed_error": "方法不被允许 (405):该端点存在,但不接受 POST 请求",
|
||||
"endpoint_not_found_error": "未找到 (404):该端点不存在",
|
||||
"endpoint_pinged": "太好了! 我们能 ping 该 webhook!",
|
||||
"endpoint_pinged_error": "无法 ping 该 webhook!",
|
||||
"endpoint_service_unavailable_error": "服务不可用 (503):服务暂时不可用",
|
||||
"learn_to_verify": "了解如何验证 webhook 签名",
|
||||
"no_triggers": "无触发器",
|
||||
"please_check_console": "请查看控制台以获取更多详情",
|
||||
"please_enter_a_url": "请输入一个 URL",
|
||||
"response_created": "创建 响应",
|
||||
@@ -1705,13 +1712,13 @@
|
||||
"show_survey_maximum_of": "显示 调查 最大 一次",
|
||||
"show_survey_to_users": "显示 问卷 给 % 的 用户",
|
||||
"show_to_x_percentage_of_targeted_users": "显示 给 {percentage}% 的 目标 用户",
|
||||
"shrink_preview": "收起预览",
|
||||
"simple": "简单",
|
||||
"six_points": "6 分",
|
||||
"smiley": "笑脸",
|
||||
"spam_protection_note": "垃圾 邮件 保护 对于 与 iOS 、 React Native 和 Android SDK 一起 显示 的 调查 无效 。 它 将 破坏 调查。",
|
||||
"spam_protection_threshold_description": "设置 值 在 0 和 1 之间,响应 低于 此 值 将 被 拒绝。",
|
||||
"spam_protection_threshold_heading": "响应 阈值",
|
||||
"shrink_preview": "收起预览",
|
||||
"star": "星",
|
||||
"starts_with": "以...开始",
|
||||
"state": "状态",
|
||||
|
||||
@@ -852,9 +852,16 @@
|
||||
"created_by_third_party": "由第三方建立",
|
||||
"discord_webhook_not_supported": "目前不支援 Discord webhooks。",
|
||||
"empty_webhook_message": "您的 Webhook 將在您新增後立即顯示在此處。⏲️",
|
||||
"endpoint_bad_gateway_error": "錯誤的閘道 (502):代理/閘道錯誤,服務無法連線",
|
||||
"endpoint_gateway_timeout_error": "閘道逾時 (504):閘道逾時,服務無法連線",
|
||||
"endpoint_internal_server_error": "內部伺服器錯誤 (500):服務遇到了未預期的錯誤",
|
||||
"endpoint_method_not_allowed_error": "不允許的方法 (405):該端點存在,但不接受 POST 請求",
|
||||
"endpoint_not_found_error": "找不到 (404):該端點不存在",
|
||||
"endpoint_pinged": "耶!我們能夠 ping Webhook!",
|
||||
"endpoint_pinged_error": "無法 ping Webhook!",
|
||||
"endpoint_service_unavailable_error": "服務無法使用 (503):服務暫時無法使用",
|
||||
"learn_to_verify": "了解如何驗證 webhook 簽章",
|
||||
"no_triggers": "無觸發條件",
|
||||
"please_check_console": "請檢查主控台以取得更多詳細資料",
|
||||
"please_enter_a_url": "請輸入網址",
|
||||
"response_created": "已建立回應",
|
||||
@@ -1705,13 +1712,13 @@
|
||||
"show_survey_maximum_of": "最多顯示問卷",
|
||||
"show_survey_to_users": "將問卷顯示給 % 的使用者",
|
||||
"show_to_x_percentage_of_targeted_users": "顯示給 '{'percentage'}'% 的目標使用者",
|
||||
"shrink_preview": "收合預覽",
|
||||
"simple": "簡單",
|
||||
"six_points": "6 分",
|
||||
"smiley": "表情符號",
|
||||
"spam_protection_note": "垃圾郵件保護不適用於使用 iOS、React Native 和 Android SDK 顯示的問卷。它會破壞問卷。",
|
||||
"spam_protection_threshold_description": "設置值在 0 和 1 之間,低於此值的回應將被拒絕。",
|
||||
"spam_protection_threshold_heading": "回應閾值",
|
||||
"shrink_preview": "收合預覽",
|
||||
"star": "星形",
|
||||
"starts_with": "開頭為",
|
||||
"state": "州/省",
|
||||
|
||||
@@ -85,9 +85,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
||||
const errMessage = err instanceof Error ? err.message : "Unknown error occurred";
|
||||
toast.error(
|
||||
`${t("environments.integrations.webhooks.endpoint_pinged_error")} \n ${
|
||||
errMessage.length < 250
|
||||
? `${t("common.error")}: ${errMessage}`
|
||||
: t("environments.integrations.webhooks.please_check_console")
|
||||
errMessage.length < 250 ? errMessage : t("environments.integrations.webhooks.please_check_console")
|
||||
}`,
|
||||
{ className: errMessage.length < 250 ? "break-all" : "" }
|
||||
);
|
||||
|
||||
@@ -9,21 +9,33 @@ import { timeSince } from "@/lib/time";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
|
||||
const renderSelectedSurveysText = (webhook: Webhook, allSurveys: TSurvey[]) => {
|
||||
let surveyNames: string[];
|
||||
|
||||
if (webhook.surveyIds.length === 0) {
|
||||
const allSurveyNames = allSurveys.map((survey) => survey.name);
|
||||
return <p className="text-slate-400">{allSurveyNames.join(", ")}</p>;
|
||||
surveyNames = allSurveys.map((survey) => survey.name);
|
||||
} else {
|
||||
const selectedSurveyNames = webhook.surveyIds.map((surveyId) => {
|
||||
const survey = allSurveys.find((survey) => survey.id === surveyId);
|
||||
return survey ? survey.name : "";
|
||||
});
|
||||
return <p className="text-slate-400">{selectedSurveyNames.join(", ")}</p>;
|
||||
surveyNames = webhook.surveyIds
|
||||
.map((surveyId) => {
|
||||
const survey = allSurveys.find((s) => s.id === surveyId);
|
||||
return survey ? survey.name : "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (surveyNames.length === 0) {
|
||||
return <p className="text-slate-400">-</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="truncate text-slate-400" title={surveyNames.join(", ")}>
|
||||
{surveyNames.join(", ")}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSelectedTriggersText = (webhook: Webhook, t: TFunction) => {
|
||||
if (webhook.triggers.length === 0) {
|
||||
return <p className="text-slate-400">No Triggers</p>;
|
||||
return <p className="text-slate-400">{t("environments.integrations.webhooks.no_triggers")}</p>;
|
||||
} else {
|
||||
let cleanedTriggers = webhook.triggers.map((trigger) => {
|
||||
if (trigger === "responseCreated") {
|
||||
|
||||
@@ -82,7 +82,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
|
||||
setHittingEndpoint(false);
|
||||
const errMessage = err instanceof Error ? err.message : "Unknown error occurred";
|
||||
toast.error(
|
||||
`${t("environments.integrations.webhooks.endpoint_pinged_error")} \n ${errMessage.length < 250 ? `${t("common.error")}: ${errMessage}` : t("environments.integrations.webhooks.please_check_console")}`,
|
||||
`${t("environments.integrations.webhooks.endpoint_pinged_error")} \n ${errMessage.length < 250 ? errMessage : t("environments.integrations.webhooks.please_check_console")}`,
|
||||
{ className: errMessage.length < 250 ? "break-all" : "" }
|
||||
);
|
||||
console.error(t("environments.integrations.webhooks.webhook_test_failed_due_to"), errMessage);
|
||||
@@ -300,7 +300,9 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
|
||||
)}
|
||||
|
||||
<Button variant="secondary" asChild>
|
||||
<Link href="https://formbricks.com/docs/api/management/webhooks" target="_blank">
|
||||
<Link
|
||||
href="https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks"
|
||||
target="_blank">
|
||||
{t("common.read_docs")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
139
apps/web/modules/integrations/webhooks/lib/webhook.test.ts
Normal file
139
apps/web/modules/integrations/webhooks/lib/webhook.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
|
||||
import { testEndpoint } from "./webhook";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
webhook: {
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
generateStandardWebhookSignature: vi.fn(() => "signed-payload"),
|
||||
generateWebhookSecret: vi.fn(() => "generated-secret"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate-webhook-url", () => ({
|
||||
validateWebhookUrl: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lingodotdev/server", () => ({
|
||||
getTranslate: vi.fn(async () => (key: string) => key),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/integrations/webhooks/lib/utils", () => ({
|
||||
isDiscordWebhook: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("uuid", () => ({
|
||||
v7: vi.fn(() => "webhook-message-id"),
|
||||
}));
|
||||
|
||||
describe("testEndpoint", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(generateStandardWebhookSignature).mockReturnValue("signed-payload");
|
||||
vi.mocked(validateWebhookUrl).mockResolvedValue(undefined);
|
||||
vi.mocked(getTranslate).mockResolvedValue((key: string) => key);
|
||||
vi.mocked(isDiscordWebhook).mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test.each([
|
||||
[500, "environments.integrations.webhooks.endpoint_internal_server_error"],
|
||||
[404, "environments.integrations.webhooks.endpoint_not_found_error"],
|
||||
[405, "environments.integrations.webhooks.endpoint_method_not_allowed_error"],
|
||||
[502, "environments.integrations.webhooks.endpoint_bad_gateway_error"],
|
||||
[503, "environments.integrations.webhooks.endpoint_service_unavailable_error"],
|
||||
[504, "environments.integrations.webhooks.endpoint_gateway_timeout_error"],
|
||||
])("throws a translated InvalidInputError for blocked status %s", async (statusCode, messageKey) => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
status: statusCode,
|
||||
}))
|
||||
);
|
||||
|
||||
await expect(testEndpoint("https://example.com/webhook", "secret")).rejects.toThrow(
|
||||
new InvalidInputError(messageKey)
|
||||
);
|
||||
|
||||
expect(validateWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
|
||||
expect(generateStandardWebhookSignature).toHaveBeenCalled();
|
||||
expect(getTranslate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("allows non-blocked non-2xx statuses", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
status: 418,
|
||||
}))
|
||||
);
|
||||
|
||||
await expect(testEndpoint("https://example.com/webhook")).resolves.toBe(true);
|
||||
expect(getTranslate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects Discord webhooks before sending the request", async () => {
|
||||
vi.mocked(isDiscordWebhook).mockReturnValue(true);
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(testEndpoint("https://discord.com/api/webhooks/123")).rejects.toThrow(
|
||||
"Discord webhooks are currently not supported."
|
||||
);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws a timeout error when the request is aborted", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((_url, init) => {
|
||||
const signal = init?.signal as AbortSignal;
|
||||
|
||||
return new Promise((_, reject) => {
|
||||
signal.addEventListener("abort", () => {
|
||||
const abortError = new Error("The operation was aborted");
|
||||
abortError.name = "AbortError";
|
||||
reject(abortError);
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const requestPromise = testEndpoint("https://example.com/webhook");
|
||||
const assertion = expect(requestPromise).rejects.toThrow("Request timed out after 5 seconds");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
await assertion;
|
||||
});
|
||||
|
||||
test("wraps unexpected fetch errors", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => Promise.reject(new Error("socket hang up")))
|
||||
);
|
||||
|
||||
await expect(testEndpoint("https://example.com/webhook")).rejects.toThrow(
|
||||
"Error while fetching the URL: socket hang up"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,9 +12,41 @@ import {
|
||||
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
|
||||
import { TWebhookInput } from "../types/webhooks";
|
||||
|
||||
const getWebhookTestErrorMessage = async (statusCode: number): Promise<string | null> => {
|
||||
switch (statusCode) {
|
||||
case 500: {
|
||||
const t = await getTranslate();
|
||||
return t("environments.integrations.webhooks.endpoint_internal_server_error");
|
||||
}
|
||||
case 404: {
|
||||
const t = await getTranslate();
|
||||
return t("environments.integrations.webhooks.endpoint_not_found_error");
|
||||
}
|
||||
case 405: {
|
||||
const t = await getTranslate();
|
||||
return t("environments.integrations.webhooks.endpoint_method_not_allowed_error");
|
||||
}
|
||||
case 502: {
|
||||
const t = await getTranslate();
|
||||
return t("environments.integrations.webhooks.endpoint_bad_gateway_error");
|
||||
}
|
||||
case 503: {
|
||||
const t = await getTranslate();
|
||||
return t("environments.integrations.webhooks.endpoint_service_unavailable_error");
|
||||
}
|
||||
case 504: {
|
||||
const t = await getTranslate();
|
||||
return t("environments.integrations.webhooks.endpoint_gateway_timeout_error");
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateWebhook = async (
|
||||
webhookId: string,
|
||||
webhookInput: Partial<TWebhookInput>
|
||||
@@ -132,14 +164,14 @@ export const getWebhooks = async (environmentId: string): Promise<Webhook[]> =>
|
||||
export const testEndpoint = async (url: string, secret?: string): Promise<boolean> => {
|
||||
await validateWebhookUrl(url);
|
||||
|
||||
if (isDiscordWebhook(url)) {
|
||||
throw new UnknownError("Discord webhooks are currently not supported.");
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
if (isDiscordWebhook(url)) {
|
||||
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" });
|
||||
@@ -165,27 +197,27 @@ export const testEndpoint = async (url: string, secret?: string): Promise<boolea
|
||||
headers: requestHeaders,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
const statusCode = response.status;
|
||||
const errorMessage = await getWebhookTestErrorMessage(statusCode);
|
||||
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
return true;
|
||||
} else {
|
||||
const errorMessage = await response.text().then(
|
||||
(text) => text.substring(0, 1000) // Limit error message size
|
||||
);
|
||||
throw new UnknownError(`Request failed with status code ${statusCode}: ${errorMessage}`);
|
||||
if (errorMessage) {
|
||||
throw new InvalidInputError(errorMessage);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new UnknownError("Request timed out after 5 seconds");
|
||||
}
|
||||
if (error instanceof UnknownError) {
|
||||
|
||||
if (error instanceof InvalidInputError || error instanceof UnknownError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new UnknownError(
|
||||
`Error while fetching the URL: ${error instanceof Error ? error.message : "Unknown error occurred"}`
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ export const TagsLoading = () => {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.workspace_configuration")}>
|
||||
<ProjectConfigNavigation activeId="tags" />
|
||||
<ProjectConfigNavigation activeId="tags" loading />
|
||||
</PageHeader>
|
||||
<SettingsCard
|
||||
title={t("environments.workspace.tags.manage_tags")}
|
||||
|
||||
@@ -26,22 +26,27 @@ import { getOrganizationBilling, getSurvey } from "@/modules/survey/lib/survey";
|
||||
import { getProject } from "./lib/project";
|
||||
|
||||
/**
|
||||
* Checks if survey follow-ups are enabled for the given organization.
|
||||
*
|
||||
* @param { string } organizationId The ID of the organization to check.
|
||||
* @returns { Promise<void> } A promise that resolves if the permission is granted.
|
||||
* @throws { ResourceNotFoundError } If the organization is not found.
|
||||
* @throws { OperationNotAllowedError } If survey follow-ups are not enabled for the organization.
|
||||
* Checks if survey follow-ups can be added for the given organization.
|
||||
* Grandfathers existing follow-ups (allows keeping them even if the org lost access).
|
||||
* Only throws when new follow-ups are being added.
|
||||
*/
|
||||
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
|
||||
const checkSurveyFollowUpsPermission = async (
|
||||
organizationId: string,
|
||||
newFollowUpIds: string[],
|
||||
oldFollowUpIds: Set<string>
|
||||
): Promise<void> => {
|
||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||
if (!organizationBilling) {
|
||||
throw new ResourceNotFoundError("Organization", organizationId);
|
||||
}
|
||||
|
||||
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
|
||||
if (!isSurveyFollowUpsEnabled) {
|
||||
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
|
||||
if (isSurveyFollowUpsEnabled) return;
|
||||
|
||||
for (const id of newFollowUpIds) {
|
||||
if (!oldFollowUpIds.has(id)) {
|
||||
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -71,14 +76,19 @@ export const updateSurveyDraftAction = authenticatedActionClient.inputSchema(ZSu
|
||||
await checkSpamProtectionPermission(organizationId);
|
||||
}
|
||||
|
||||
if (survey.followUps?.length) {
|
||||
await checkSurveyFollowUpsPermission(organizationId);
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = survey.id;
|
||||
const oldObject = await getSurvey(survey.id);
|
||||
|
||||
if (survey.followUps.length) {
|
||||
const oldFollowUpIds = new Set((oldObject?.followUps ?? []).map((f) => f.id));
|
||||
await checkSurveyFollowUpsPermission(
|
||||
organizationId,
|
||||
survey.followUps.map((f) => f.id),
|
||||
oldFollowUpIds
|
||||
);
|
||||
}
|
||||
|
||||
await checkExternalUrlsPermission(organizationId, survey, oldObject);
|
||||
|
||||
// Use the draft version that skips validation
|
||||
@@ -116,14 +126,19 @@ export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey)
|
||||
await checkSpamProtectionPermission(organizationId);
|
||||
}
|
||||
|
||||
if (parsedInput.followUps?.length) {
|
||||
await checkSurveyFollowUpsPermission(organizationId);
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.id;
|
||||
const oldObject = await getSurvey(parsedInput.id);
|
||||
|
||||
if (parsedInput.followUps?.length) {
|
||||
const oldFollowUpIds = new Set((oldObject?.followUps ?? []).map((f) => f.id));
|
||||
await checkSurveyFollowUpsPermission(
|
||||
organizationId,
|
||||
parsedInput.followUps.map((f) => f.id),
|
||||
oldFollowUpIds
|
||||
);
|
||||
}
|
||||
|
||||
// Check external URLs permission (with grandfathering)
|
||||
await checkExternalUrlsPermission(organizationId, parsedInput, oldObject);
|
||||
const result = await updateSurvey(parsedInput);
|
||||
|
||||
@@ -56,9 +56,16 @@ export const CustomScriptsInjector = ({
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
});
|
||||
|
||||
// Copy inline script content
|
||||
// Copy inline script content, wrapped in try-catch to prevent runtime errors
|
||||
if (script.textContent) {
|
||||
newScript.textContent = script.textContent;
|
||||
// Wrap inline scripts in try-catch to prevent user script errors from breaking the survey
|
||||
newScript.textContent = `
|
||||
try {
|
||||
${script.textContent}
|
||||
} catch (error) {
|
||||
console.warn("[Formbricks] Error in custom script:", error);
|
||||
}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
document.head.appendChild(newScript);
|
||||
|
||||
@@ -30,13 +30,14 @@ You can create webhooks either through the **Formbricks App UI** or programmatic
|
||||
|
||||

|
||||
|
||||
- Add your webhook listener endpoint & test it to make sure it can receive the test endpoint otherwise you will not be able to save it.
|
||||
- Add your webhook listener endpoint and test it to make sure the endpoint is reachable and accepts `POST`
|
||||
requests.
|
||||
|
||||

|
||||
|
||||
- Now add the triggers you want to listen to and the surveys!
|
||||
|
||||
- That’s it! Your webhooks will not start receiving data as soon as it arrives!
|
||||
- That’s it! Your webhooks will now start receiving data as soon as it arrives!
|
||||
|
||||

|
||||
|
||||
@@ -44,6 +45,31 @@ You can create webhooks either through the **Formbricks App UI** or programmatic
|
||||
|
||||
Use our documented methods on the **Creation**, **List**, and **Deletion** endpoints of the Webhook API mentioned in our [API v2 playground](https://formbricks.com/docs/api-v2-reference/management-api-%3E-webhooks/get-webhooks).
|
||||
|
||||
## Testing Webhooks Locally
|
||||
|
||||
If you want to test a webhook consumer running on your machine before deploying it, you can expose your local
|
||||
endpoint with [ngrok](https://ngrok.com/docs/universal-gateway/http).
|
||||
|
||||
<Steps>
|
||||
<Step title="Start your local webhook listener">
|
||||
Run your local endpoint on a port like `3000`.
|
||||
</Step>
|
||||
<Step title="Expose it with ngrok">
|
||||
Create a public HTTPS URL for your local service, for example with `ngrok http http://localhost:3000`.
|
||||
</Step>
|
||||
|
||||
<Step title="Use the public URL in Formbricks">
|
||||
Paste the ngrok URL into your webhook endpoint, click **Test Endpoint**, and then save the webhook once the
|
||||
endpoint is reachable.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Note>
|
||||
To avoid sending unwanted test responses to production workflows, copy the survey to your [Test
|
||||
Environment](/xm-and-surveys/core-features/test-environment) and use that survey copy in your development
|
||||
workflow while validating the webhook setup.
|
||||
</Note>
|
||||
|
||||
If you encounter any issues or need help setting up webhooks, feel free to reach out to us on [GitHub Discussions](https://github.com/formbricks/formbricks/discussions). 😃
|
||||
|
||||
---
|
||||
@@ -220,49 +246,47 @@ We provide the following webhook payloads, `responseCreated`, `responseUpdated`,
|
||||
Example of Response Created webhook payload:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
{
|
||||
"data": {
|
||||
"contact": null,
|
||||
"contactAttributes": null,
|
||||
"createdAt": "2025-07-24T07:47:29.507Z",
|
||||
"data": {
|
||||
"contact": null,
|
||||
"contactAttributes": null,
|
||||
"createdAt": "2025-07-24T07:47:29.507Z",
|
||||
"data": {
|
||||
"q1": "clicked"
|
||||
},
|
||||
"displayId": "displayId",
|
||||
"endingId": null,
|
||||
"finished": false,
|
||||
"id": "responseId",
|
||||
"language": "en",
|
||||
"meta": {
|
||||
"country": "DE",
|
||||
"url": "https://app.formbricks.com/s/surveyId",
|
||||
"userAgent": {
|
||||
"browser": "Chrome",
|
||||
"device": "desktop",
|
||||
"os": "macOS"
|
||||
}
|
||||
},
|
||||
"singleUseId": null,
|
||||
"survey": {
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"status": "inProgress",
|
||||
"title": "Customer Satisfaction Survey",
|
||||
"type": "link",
|
||||
"updatedAt": "2025-07-24T07:45:00.000Z"
|
||||
},
|
||||
"surveyId": "surveyId",
|
||||
"tags": [],
|
||||
"ttc": {
|
||||
"q1": 2154.700000047684
|
||||
},
|
||||
"updatedAt": "2025-07-24T07:47:29.507Z",
|
||||
"variables": {}
|
||||
"welcome_card_cta": "clicked"
|
||||
},
|
||||
"event": "responseCreated",
|
||||
"webhookId": "webhookId"
|
||||
}
|
||||
]
|
||||
"displayId": "displayId",
|
||||
"endingId": null,
|
||||
"finished": false,
|
||||
"id": "responseId",
|
||||
"language": "en",
|
||||
"meta": {
|
||||
"country": "DE",
|
||||
"url": "https://app.formbricks.com/s/surveyId",
|
||||
"userAgent": {
|
||||
"browser": "Chrome",
|
||||
"device": "desktop",
|
||||
"os": "macOS"
|
||||
}
|
||||
},
|
||||
"singleUseId": null,
|
||||
"survey": {
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"status": "inProgress",
|
||||
"title": "Customer Satisfaction Survey",
|
||||
"type": "link",
|
||||
"updatedAt": "2025-07-24T07:45:00.000Z"
|
||||
},
|
||||
"surveyId": "surveyId",
|
||||
"tags": [],
|
||||
"ttc": {
|
||||
"welcome_card_cta": 2154.700000047684
|
||||
},
|
||||
"updatedAt": "2025-07-24T07:47:29.507Z",
|
||||
"variables": {}
|
||||
},
|
||||
"event": "responseCreated",
|
||||
"webhookId": "webhookId"
|
||||
}
|
||||
```
|
||||
|
||||
### Response Updated
|
||||
@@ -270,51 +294,49 @@ Example of Response Created webhook payload:
|
||||
Example of Response Updated webhook payload:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
{
|
||||
"data": {
|
||||
"contact": null,
|
||||
"contactAttributes": null,
|
||||
"createdAt": "2025-07-24T07:47:29.507Z",
|
||||
"data": {
|
||||
"contact": null,
|
||||
"contactAttributes": null,
|
||||
"createdAt": "2025-07-24T07:47:29.507Z",
|
||||
"data": {
|
||||
"q1": "clicked",
|
||||
"q2": "Just browsing"
|
||||
},
|
||||
"displayId": "displayId",
|
||||
"endingId": null,
|
||||
"finished": false,
|
||||
"id": "responseId",
|
||||
"language": "en",
|
||||
"meta": {
|
||||
"country": "DE",
|
||||
"url": "https://app.formbricks.com/s/surveyId",
|
||||
"userAgent": {
|
||||
"browser": "Chrome",
|
||||
"device": "desktop",
|
||||
"os": "macOS"
|
||||
}
|
||||
},
|
||||
"singleUseId": null,
|
||||
"survey": {
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"status": "inProgress",
|
||||
"title": "Customer Satisfaction Survey",
|
||||
"type": "link",
|
||||
"updatedAt": "2025-07-24T07:45:00.000Z"
|
||||
},
|
||||
"surveyId": "surveyId",
|
||||
"tags": [],
|
||||
"ttc": {
|
||||
"q1": 2154.700000047684,
|
||||
"q2": 3855.799999952316
|
||||
},
|
||||
"updatedAt": "2025-07-24T07:47:33.696Z",
|
||||
"variables": {}
|
||||
"visit_reason": "Just browsing",
|
||||
"welcome_card_cta": "clicked"
|
||||
},
|
||||
"event": "responseUpdated",
|
||||
"webhookId": "webhookId"
|
||||
}
|
||||
]
|
||||
"displayId": "displayId",
|
||||
"endingId": null,
|
||||
"finished": false,
|
||||
"id": "responseId",
|
||||
"language": "en",
|
||||
"meta": {
|
||||
"country": "DE",
|
||||
"url": "https://app.formbricks.com/s/surveyId",
|
||||
"userAgent": {
|
||||
"browser": "Chrome",
|
||||
"device": "desktop",
|
||||
"os": "macOS"
|
||||
}
|
||||
},
|
||||
"singleUseId": null,
|
||||
"survey": {
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"status": "inProgress",
|
||||
"title": "Customer Satisfaction Survey",
|
||||
"type": "link",
|
||||
"updatedAt": "2025-07-24T07:45:00.000Z"
|
||||
},
|
||||
"surveyId": "surveyId",
|
||||
"tags": [],
|
||||
"ttc": {
|
||||
"visit_reason": 3855.799999952316,
|
||||
"welcome_card_cta": 2154.700000047684
|
||||
},
|
||||
"updatedAt": "2025-07-24T07:47:33.696Z",
|
||||
"variables": {}
|
||||
},
|
||||
"event": "responseUpdated",
|
||||
"webhookId": "webhookId"
|
||||
}
|
||||
```
|
||||
|
||||
### Response Finished
|
||||
@@ -322,50 +344,48 @@ Example of Response Updated webhook payload:
|
||||
Example of Response Finished webhook payload:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
{
|
||||
"data": {
|
||||
"contact": null,
|
||||
"contactAttributes": null,
|
||||
"createdAt": "2025-07-24T07:47:29.507Z",
|
||||
"data": {
|
||||
"contact": null,
|
||||
"contactAttributes": null,
|
||||
"createdAt": "2025-07-24T07:47:29.507Z",
|
||||
"data": {
|
||||
"q1": "clicked",
|
||||
"q2": "accepted"
|
||||
},
|
||||
"displayId": "displayId",
|
||||
"endingId": "endingId",
|
||||
"finished": true,
|
||||
"id": "responseId",
|
||||
"language": "en",
|
||||
"meta": {
|
||||
"country": "DE",
|
||||
"url": "https://app.formbricks.com/s/surveyId",
|
||||
"userAgent": {
|
||||
"browser": "Chrome",
|
||||
"device": "desktop",
|
||||
"os": "macOS"
|
||||
}
|
||||
},
|
||||
"singleUseId": null,
|
||||
"survey": {
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"status": "inProgress",
|
||||
"title": "Customer Satisfaction Survey",
|
||||
"type": "link",
|
||||
"updatedAt": "2025-07-24T07:45:00.000Z"
|
||||
},
|
||||
"surveyId": "surveyId",
|
||||
"tags": [],
|
||||
"ttc": {
|
||||
"_total": 4947.899999035763,
|
||||
"q1": 2154.700000047684,
|
||||
"q2": 2793.199999988079
|
||||
},
|
||||
"updatedAt": "2025-07-24T07:47:56.116Z",
|
||||
"variables": {}
|
||||
"newsletter_consent": "accepted",
|
||||
"welcome_card_cta": "clicked"
|
||||
},
|
||||
"event": "responseFinished",
|
||||
"webhookId": "webhookId"
|
||||
}
|
||||
]
|
||||
"displayId": "displayId",
|
||||
"endingId": "endingId",
|
||||
"finished": true,
|
||||
"id": "responseId",
|
||||
"language": "en",
|
||||
"meta": {
|
||||
"country": "DE",
|
||||
"url": "https://app.formbricks.com/s/surveyId",
|
||||
"userAgent": {
|
||||
"browser": "Chrome",
|
||||
"device": "desktop",
|
||||
"os": "macOS"
|
||||
}
|
||||
},
|
||||
"singleUseId": null,
|
||||
"survey": {
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"status": "inProgress",
|
||||
"title": "Customer Satisfaction Survey",
|
||||
"type": "link",
|
||||
"updatedAt": "2025-07-24T07:45:00.000Z"
|
||||
},
|
||||
"surveyId": "surveyId",
|
||||
"tags": [],
|
||||
"ttc": {
|
||||
"_total": 4947.899999035763,
|
||||
"newsletter_consent": 2793.199999988079,
|
||||
"welcome_card_cta": 2154.700000047684
|
||||
},
|
||||
"updatedAt": "2025-07-24T07:47:56.116Z",
|
||||
"variables": {}
|
||||
},
|
||||
"event": "responseFinished",
|
||||
"webhookId": "webhookId"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Logger } from "@/lib/common/logger";
|
||||
import { getIsSetup, setIsSetup } from "@/lib/common/status";
|
||||
import { filterSurveys, getIsDebug, isNowExpired, wrapThrows } from "@/lib/common/utils";
|
||||
import { fetchEnvironmentState } from "@/lib/environment/state";
|
||||
import { closeSurvey } from "@/lib/survey/widget";
|
||||
import { closeSurvey, preloadSurveysScript } from "@/lib/survey/widget";
|
||||
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
|
||||
import { sendUpdatesToBackend } from "@/lib/user/update";
|
||||
import {
|
||||
@@ -316,6 +316,9 @@ export const setup = async (
|
||||
addEventListeners();
|
||||
addCleanupEventListeners();
|
||||
|
||||
// Preload surveys script so it's ready when a survey triggers
|
||||
preloadSurveysScript(configInput.appUrl);
|
||||
|
||||
setIsSetup(true);
|
||||
logger.debug("Set up complete");
|
||||
|
||||
|
||||
@@ -70,6 +70,12 @@ vi.mock("@/lib/survey/no-code-action", () => ({
|
||||
checkPageUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
// 9) Mock survey widget
|
||||
vi.mock("@/lib/survey/widget", () => ({
|
||||
closeSurvey: vi.fn(),
|
||||
preloadSurveysScript: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("setup.ts", () => {
|
||||
let getInstanceConfigMock: MockInstance<() => Config>;
|
||||
let getInstanceLoggerMock: MockInstance<() => Logger>;
|
||||
|
||||
@@ -67,6 +67,8 @@ describe("widget-file", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
// @ts-expect-error -- cleaning up mock
|
||||
delete window.formbricksSurveys;
|
||||
|
||||
getInstanceConfigMock = vi.spyOn(Config, "getInstance");
|
||||
getInstanceLoggerMock = vi.spyOn(Logger, "getInstance").mockReturnValue(mockLogger as unknown as Logger);
|
||||
@@ -464,6 +466,214 @@ describe("widget-file", () => {
|
||||
expect(window.formbricksSurveys.renderSurvey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("loadFormbricksSurveysExternally and waitForSurveysGlobal", () => {
|
||||
const scriptLoadMockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
appUrl: "https://fake.app",
|
||||
environmentId: "env_123",
|
||||
environment: {
|
||||
data: {
|
||||
project: {
|
||||
clickOutsideClose: true,
|
||||
overlay: "none",
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
data: {
|
||||
userId: "user_abc",
|
||||
contactId: "contact_abc",
|
||||
displays: [],
|
||||
responses: [],
|
||||
lastDisplayAt: null,
|
||||
language: "en",
|
||||
},
|
||||
},
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
// Helper to get the script element passed to document.head.appendChild
|
||||
const getAppendedScript = (): Record<string, unknown> => {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- accessing mock for test assertions
|
||||
const appendChildMock = vi.mocked(document.head.appendChild);
|
||||
for (const call of appendChildMock.mock.calls) {
|
||||
const el = call[0] as unknown as Record<string, unknown>;
|
||||
if (typeof el.src === "string" && el.src.includes("surveys.umd.cjs")) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
throw new Error("No script element for surveys.umd.cjs was appended to document.head");
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mock return values that may have been overridden by previous tests
|
||||
mockUpdateQueue.hasPendingWork.mockReturnValue(false);
|
||||
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
// Test onerror first so surveysLoadPromise is reset to null for subsequent tests
|
||||
test("rejects when script fails to load (onerror) and allows retry", async () => {
|
||||
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- suppress console.error in test
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const renderPromise = widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
const scriptEl = getAppendedScript();
|
||||
|
||||
expect(scriptEl.src).toBe("https://fake.app/js/surveys.umd.cjs");
|
||||
expect(scriptEl.async).toBe(true);
|
||||
|
||||
// Simulate network error
|
||||
(scriptEl.onerror as (error: unknown) => void)("Network error");
|
||||
|
||||
// renderWidget catches the error internally — it resolves, not rejects
|
||||
await renderPromise;
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to load Formbricks Surveys library:", "Network error");
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("rejects when script loads but surveys global never becomes available (timeout)", async () => {
|
||||
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- suppress console.error in test
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.useFakeTimers();
|
||||
|
||||
const renderPromise = widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
const scriptEl = getAppendedScript();
|
||||
|
||||
// Script loaded but window.formbricksSurveys is never set
|
||||
(scriptEl.onload as () => void)();
|
||||
|
||||
// Advance past the 10s timeout (polls every 200ms)
|
||||
await vi.advanceTimersByTimeAsync(10001);
|
||||
|
||||
await renderPromise;
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Failed to load Formbricks Surveys library:",
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("resolves after polling when surveys global becomes available and applies stored nonce", async () => {
|
||||
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// Set nonce before surveys load to test nonce application
|
||||
window.__formbricksNonce = "test-nonce-123";
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
const renderPromise = widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
const scriptEl = getAppendedScript();
|
||||
|
||||
// Simulate script loaded
|
||||
(scriptEl.onload as () => void)();
|
||||
|
||||
// Set the global after script "loads" — simulates browser finishing execution
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
|
||||
|
||||
// Advance one polling interval for waitForSurveysGlobal to find it
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
await renderPromise;
|
||||
|
||||
// Run remaining timers for survey.delay setTimeout
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(window.formbricksSurveys.setNonce).toHaveBeenCalledWith("test-nonce-123");
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
appUrl: "https://fake.app",
|
||||
environmentId: "env_123",
|
||||
contactId: "contact_abc",
|
||||
})
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
delete window.__formbricksNonce;
|
||||
});
|
||||
|
||||
test("deduplicates concurrent calls (returns cached promise)", async () => {
|
||||
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// After the previous successful test, surveysLoadPromise holds a resolved promise.
|
||||
// Calling renderWidget again (without formbricksSurveys on window, but with cached promise)
|
||||
// should reuse the cached promise rather than creating a new script element.
|
||||
// @ts-expect-error -- cleaning up mock to force dedup path
|
||||
delete window.formbricksSurveys;
|
||||
|
||||
const appendChildSpy = vi.spyOn(document.head, "appendChild");
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
await widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
// No new script element should have been appended (dedup via early return or cached promise)
|
||||
const scriptAppendCalls = appendChildSpy.mock.calls.filter((call: unknown[]) => {
|
||||
const el = call[0] as Record<string, unknown> | undefined;
|
||||
return typeof el?.src === "string" && el.src.includes("surveys.umd.cjs");
|
||||
});
|
||||
expect(scriptAppendCalls.length).toBe(0);
|
||||
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
test("preloadSurveysScript adds a preload link and deduplicates subsequent calls", () => {
|
||||
const createElementSpy = vi.spyOn(document, "createElement");
|
||||
const appendChildSpy = vi.spyOn(document.head, "appendChild");
|
||||
|
||||
widget.preloadSurveysScript("https://fake.app");
|
||||
|
||||
expect(createElementSpy).toHaveBeenCalledWith("link");
|
||||
expect(appendChildSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const linkEl = createElementSpy.mock.results[0].value as Record<string, string>;
|
||||
expect(linkEl.rel).toBe("preload");
|
||||
expect(linkEl.as).toBe("script");
|
||||
expect(linkEl.href).toBe("https://fake.app/js/surveys.umd.cjs");
|
||||
|
||||
// Second call should be a no-op (deduplication)
|
||||
widget.preloadSurveysScript("https://fake.app");
|
||||
expect(appendChildSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("renderWidget proceeds when identification fails but survey has no segment filters", async () => {
|
||||
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
|
||||
mockUpdateQueue.waitForPendingWork.mockResolvedValue(false);
|
||||
|
||||
@@ -106,7 +106,15 @@ export const renderWidget = async (
|
||||
const overlay = projectOverwrites.overlay ?? project.overlay;
|
||||
const placement = projectOverwrites.placement ?? project.placement;
|
||||
const isBrandingEnabled = project.inAppSurveyBranding;
|
||||
const formbricksSurveys = await loadFormbricksSurveysExternally();
|
||||
|
||||
let formbricksSurveys: TFormbricksSurveys;
|
||||
try {
|
||||
formbricksSurveys = await loadFormbricksSurveysExternally();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load surveys library: ${String(error)}`);
|
||||
setIsSurveyRunning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const recaptchaSiteKey = config.get().environment.data.recaptchaSiteKey;
|
||||
const isSpamProtectionEnabled = Boolean(recaptchaSiteKey && survey.recaptcha?.enabled);
|
||||
@@ -219,30 +227,87 @@ export const removeWidgetContainer = (): void => {
|
||||
document.getElementById(CONTAINER_ID)?.remove();
|
||||
};
|
||||
|
||||
const loadFormbricksSurveysExternally = (): Promise<typeof globalThis.window.formbricksSurveys> => {
|
||||
const config = Config.getInstance();
|
||||
const SURVEYS_LOAD_TIMEOUT_MS = 10000;
|
||||
const SURVEYS_POLL_INTERVAL_MS = 200;
|
||||
|
||||
type TFormbricksSurveys = typeof globalThis.window.formbricksSurveys;
|
||||
|
||||
let surveysLoadPromise: Promise<TFormbricksSurveys> | null = null;
|
||||
|
||||
const waitForSurveysGlobal = (): Promise<TFormbricksSurveys> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- We need to check if the formbricksSurveys object exists
|
||||
if (globalThis.window.formbricksSurveys) {
|
||||
resolve(globalThis.window.formbricksSurveys);
|
||||
} else {
|
||||
const script = document.createElement("script");
|
||||
script.src = `${config.get().appUrl}/js/surveys.umd.cjs`;
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
// Apply stored nonce if it was set before surveys package loaded
|
||||
const startTime = Date.now();
|
||||
|
||||
const check = (): void => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
|
||||
if (globalThis.window.formbricksSurveys) {
|
||||
const storedNonce = globalThis.window.__formbricksNonce;
|
||||
if (storedNonce) {
|
||||
globalThis.window.formbricksSurveys.setNonce(storedNonce);
|
||||
}
|
||||
resolve(globalThis.window.formbricksSurveys);
|
||||
};
|
||||
script.onerror = (error) => {
|
||||
console.error("Failed to load Formbricks Surveys library:", error);
|
||||
reject(new Error(`Failed to load Formbricks Surveys library: ${error as string}`));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() - startTime >= SURVEYS_LOAD_TIMEOUT_MS) {
|
||||
reject(new Error("Formbricks Surveys library did not become available within timeout"));
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(check, SURVEYS_POLL_INTERVAL_MS);
|
||||
};
|
||||
|
||||
check();
|
||||
});
|
||||
};
|
||||
|
||||
const loadFormbricksSurveysExternally = (): Promise<TFormbricksSurveys> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
|
||||
if (globalThis.window.formbricksSurveys) {
|
||||
return Promise.resolve(globalThis.window.formbricksSurveys);
|
||||
}
|
||||
|
||||
if (surveysLoadPromise) {
|
||||
return surveysLoadPromise;
|
||||
}
|
||||
|
||||
surveysLoadPromise = new Promise<TFormbricksSurveys>((resolve, reject: (error: unknown) => void) => {
|
||||
const config = Config.getInstance();
|
||||
const script = document.createElement("script");
|
||||
script.src = `${config.get().appUrl}/js/surveys.umd.cjs`;
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
waitForSurveysGlobal()
|
||||
.then(resolve)
|
||||
.catch((error: unknown) => {
|
||||
surveysLoadPromise = null;
|
||||
console.error("Failed to load Formbricks Surveys library:", error);
|
||||
reject(new Error(`Failed to load Formbricks Surveys library`));
|
||||
});
|
||||
};
|
||||
script.onerror = (error) => {
|
||||
surveysLoadPromise = null;
|
||||
console.error("Failed to load Formbricks Surveys library:", error);
|
||||
reject(new Error(`Failed to load Formbricks Surveys library`));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return surveysLoadPromise;
|
||||
};
|
||||
|
||||
let isPreloaded = false;
|
||||
|
||||
export const preloadSurveysScript = (appUrl: string): void => {
|
||||
// Don't preload if already loaded or already preloading
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
|
||||
if (globalThis.window.formbricksSurveys) return;
|
||||
if (isPreloaded) return;
|
||||
|
||||
isPreloaded = true;
|
||||
const link = document.createElement("link");
|
||||
link.rel = "preload";
|
||||
link.as = "script";
|
||||
link.href = `${appUrl}/js/surveys.umd.cjs`;
|
||||
document.head.appendChild(link);
|
||||
};
|
||||
|
||||
@@ -64,7 +64,7 @@ packages/surveys/
|
||||
|
||||
```bash
|
||||
# packages/surveys/.env
|
||||
LINGODOTDEV_API_KEY=<YOUR_API_KEY>
|
||||
LINGO_API_KEY=<YOUR_API_KEY>
|
||||
```
|
||||
|
||||
4. **Generate Translations**
|
||||
|
||||
Reference in New Issue
Block a user