Compare commits

...

5 Commits

57 changed files with 230 additions and 51 deletions
@@ -107,7 +107,9 @@ export const SummaryMetadata = ({
label={t("environments.surveys.summary.time_to_complete")}
percentage={null}
value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`}
tooltipText={t("environments.surveys.summary.ttc_tooltip")}
tooltipText={t("environments.surveys.summary.ttc_survey_tooltip", {
defaultValue: "Average time to complete the survey.",
})}
isLoading={isLoading}
/>
@@ -164,7 +164,7 @@ describe("getSurveySummaryMeta", () => {
});
test("calculates meta correctly", () => {
const meta = getSurveySummaryMeta(mockResponses, 10, mockQuotas);
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 10, mockQuotas);
expect(meta.displayCount).toBe(10);
expect(meta.totalResponses).toBe(3);
expect(meta.startsPercentage).toBe(30);
@@ -178,13 +178,13 @@ describe("getSurveySummaryMeta", () => {
});
test("handles zero display count", () => {
const meta = getSurveySummaryMeta(mockResponses, 0, mockQuotas);
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 0, mockQuotas);
expect(meta.startsPercentage).toBe(0);
expect(meta.completedPercentage).toBe(0);
});
test("handles zero responses", () => {
const meta = getSurveySummaryMeta([], 10, mockQuotas);
const meta = getSurveySummaryMeta(mockBaseSurvey, [], 10, mockQuotas);
expect(meta.totalResponses).toBe(0);
expect(meta.completedResponses).toBe(0);
expect(meta.dropOffCount).toBe(0);
@@ -274,7 +274,7 @@ describe("getSurveySummaryDropOff", () => {
expect(dropOff[1].impressions).toBe(2);
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
expect(dropOff[1].ttc).toBe(7.5); // avg of r1(5ms) and r2(10ms)
expect(dropOff[1].ttc).toBe(10); // block-level TTC uses max block time per response
});
test("drop-off attributed to last seen element when user doesn't reach next question", () => {
@@ -51,7 +51,32 @@ interface TSurveySummaryResponse {
finished: boolean;
}
const getElementIdToBlockIdMap = (survey: TSurvey): Record<string, string> => {
return survey.blocks.reduce<Record<string, string>>((acc, block) => {
block.elements.forEach((element) => {
acc[element.id] = block.id;
});
return acc;
}, {});
};
const getBlockTimesForResponse = (
response: TSurveySummaryResponse,
survey: TSurvey
): Record<string, number> => {
return survey.blocks.reduce<Record<string, number>>((acc, block) => {
const maxElementTtc = block.elements.reduce((maxTtc, element) => {
const elementTtc = response.ttc?.[element.id] ?? 0;
return Math.max(maxTtc, elementTtc);
}, 0);
acc[block.id] = maxElementTtc;
return acc;
}, {});
};
export const getSurveySummaryMeta = (
survey: TSurvey,
responses: TSurveySummaryResponse[],
displayCount: number,
quotas: TSurveySummary["quotas"]
@@ -60,9 +85,15 @@ export const getSurveySummaryMeta = (
let ttcResponseCount = 0;
const ttcSum = responses.reduce((acc, response) => {
if (response.ttc?._total) {
const blockTimes = getBlockTimesForResponse(response, survey);
const responseBlockTtcTotal = Object.values(blockTimes).reduce((sum, ttc) => sum + ttc, 0);
// Fallback to _total for malformed surveys with no block mappings.
const responseTtcTotal = responseBlockTtcTotal > 0 ? responseBlockTtcTotal : (response.ttc?._total ?? 0);
if (responseTtcTotal > 0) {
ttcResponseCount++;
return acc + response.ttc._total;
return acc + responseTtcTotal;
}
return acc;
}, 0);
@@ -117,12 +148,16 @@ export const getSurveySummaryDropOff = (
let dropOffArr = new Array(elements.length).fill(0) as number[];
let impressionsArr = new Array(elements.length).fill(0) as number[];
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
const elementIdToBlockId = getElementIdToBlockIdMap(survey);
responses.forEach((response) => {
// Calculate total time-to-completion per element
const blockTimes = getBlockTimesForResponse(response, survey);
Object.keys(totalTtc).forEach((elementId) => {
if (response.ttc && response.ttc[elementId]) {
totalTtc[elementId] += response.ttc[elementId];
const blockId = elementIdToBlockId[elementId];
const blockTtc = blockId ? (blockTimes[blockId] ?? 0) : 0;
if (blockTtc > 0) {
totalTtc[elementId] += blockTtc;
responseCounts[elementId]++;
}
});
@@ -974,10 +1009,8 @@ export const getSurveySummary = reactCache(
]);
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount);
const [meta, elementSummary] = await Promise.all([
getSurveySummaryMeta(responses, displayCount, quotas),
getElementSummary(survey, elements, responses, dropOff),
]);
const meta = getSurveySummaryMeta(survey, responses, displayCount, quotas);
const elementSummary = await getElementSummary(survey, elements, responses, dropOff);
return {
meta,
@@ -0,0 +1,33 @@
import { capturePostHogEvent } from "@/lib/posthog";
interface SurveyResponsePostHogEventParams {
organizationId: string;
surveyId: string;
surveyType: string;
environmentId: string;
responseCount: number;
}
/**
* Captures a PostHog event for survey responses at milestones:
* 1st response, then every 100th (100, 200, 300, ...).
*/
export const captureSurveyResponsePostHogEvent = ({
organizationId,
surveyId,
surveyType,
environmentId,
responseCount,
}: SurveyResponsePostHogEventParams): void => {
if (responseCount !== 1 && responseCount % 100 !== 0) return;
capturePostHogEvent(organizationId, "survey_response_received", {
survey_id: surveyId,
survey_type: surveyType,
organization_id: organizationId,
environment_id: environmentId,
response_count: responseCount,
is_first_response: responseCount === 1,
milestone: responseCount === 1 ? "first" : String(responseCount),
});
};
+9 -20
View File
@@ -1,7 +1,6 @@
import { PipelineTriggers, Webhook } from "@prisma/client";
import { headers } from "next/headers";
import { v7 as uuidv7 } from "uuid";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
@@ -9,12 +8,10 @@ import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { cache } from "@/lib/cache";
import { CRON_SECRET, POSTHOG_KEY } from "@/lib/constants";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { getIntegrations } from "@/lib/integration/service";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
@@ -27,6 +24,7 @@ import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { handleIntegrations } from "./lib/handleIntegrations";
import { captureSurveyResponsePostHogEvent } from "./lib/posthog";
export const POST = async (request: Request) => {
const requestHeaders = await headers();
@@ -302,25 +300,16 @@ export const POST = async (request: Request) => {
logger.error({ error, responseId: response.id }, "Failed to record response meter event");
});
// Sampled PostHog tracking: first response + every 100th
if (POSTHOG_KEY) {
const responseCount = await cache.withCache(
() => getResponseCountBySurveyId(surveyId),
createCacheKey.response.countBySurveyId(surveyId),
60 * 1000
);
const responseCount = await getResponseCountBySurveyId(surveyId);
if (responseCount === 1 || responseCount % 100 === 0) {
capturePostHogEvent(organization.id, "survey_response_received", {
survey_id: surveyId,
survey_type: survey.type,
organization_id: organization.id,
environment_id: environmentId,
response_count: responseCount,
is_first_response: responseCount === 1,
milestone: responseCount === 1 ? "first" : String(responseCount),
});
}
captureSurveyResponsePostHogEvent({
organizationId: organization.id,
surveyId,
surveyType: survey.type,
environmentId,
responseCount,
});
}
// Send telemetry events
@@ -1,5 +1,6 @@
import { google } from "googleapis";
import { getServerSession } from "next-auth";
import { logger } from "@formbricks/logger";
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
import { responses } from "@/app/lib/api/response";
import {
@@ -10,6 +11,8 @@ import {
} from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (req: Request) => {
@@ -82,6 +85,16 @@ export const GET = async (req: Request) => {
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(session.user.id, "integration_connected", {
integration_type: "googleSheets",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
}
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
);
@@ -6,6 +6,8 @@ import { fetchAirtableAuthToken } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
const getEmail = async (token: string) => {
const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", {
@@ -86,6 +88,17 @@ export const GET = withV1ApiWrapper({
},
};
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "airtable",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for airtable");
}
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/airtable`
@@ -1,3 +1,4 @@
import { logger } from "@formbricks/logger";
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -11,6 +12,8 @@ import {
import { symmetricEncrypt } from "@/lib/crypto";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
@@ -96,6 +99,16 @@ export const GET = withV1ApiWrapper({
const result = await createOrUpdateIntegration(environmentId, notionIntegration);
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "notion",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for notion");
}
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/notion`
@@ -1,3 +1,4 @@
import { logger } from "@formbricks/logger";
import {
TIntegrationSlackConfig,
TIntegrationSlackConfigData,
@@ -8,6 +9,8 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
@@ -104,6 +107,16 @@ export const GET = withV1ApiWrapper({
const result = await createOrUpdateIntegration(environmentId, integration);
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "slack",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for slack");
}
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/slack`
+4
View File
@@ -51,6 +51,8 @@ checksums:
auth/login/login_with_email: 4198b691f5d2bf2f443a03cc9fffd17f
auth/login/lost_access: 917c4665b99c37377ed522ba53249006
auth/login/new_to_formbricks: 1a1d45aca05bb21eb8f795d7d62dc4e3
auth/login/oauth_account_not_linked_description: 74627dc30666699b21de093d16d83312
auth/login/oauth_account_not_linked_title: 2eb8e132ed37b3b87c1dec392c224933
auth/login/use_a_backup_code: 181e4ab6ba9e5b063b46925f1925eb2b
auth/saml_connection_error: 03c69c534e7eaafcb2c22b7daf9f3efc
auth/signup/captcha_failed: 4e1ed87800585b8c1da1514fa86ab943
@@ -411,6 +413,7 @@ checksums:
common/team_name: 549d949de4b9adad4afd6427a60a329e
common/team_role: 66db395781aef64ef3791417b3b67c0b
common/teams: b63448c05270497973ac4407047dae02
common/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
common/text: 4ddccc1974775ed7357f9beaf9361cec
common/time: b504a03d52e8001bfdc5cb6205364f42
common/time_to_finish: c8f6abdb886bee3619bb50b08fada5fa
@@ -2021,6 +2024,7 @@ checksums:
environments/surveys/summary/this_quarter: 9c77d94783dff2269c069389122cd7bd
environments/surveys/summary/this_year: 1e69651c2ac722f8ce138f43cf2e02f9
environments/surveys/summary/time_to_complete: ac14edd54df964d2d5ae07b97ae4091f
environments/surveys/summary/ttc_survey_tooltip: 9bd3971cb94670c54d74a4e86ee53172
environments/surveys/summary/ttc_tooltip: 9b1cbe32cc81111314bd3b6fd050c2e7
environments/surveys/summary/unknown_question_type: e4152a7457d2b94f48dcc70aaba9922f
environments/surveys/summary/use_personal_links: da2b3e7e1aaf2ea2bd4efed2dda4247c
+5 -1
View File
@@ -81,7 +81,11 @@ export const extractChoiceIdsFromResponse = (
if (Array.isArray(responseValue)) {
// Multiple choice case - response is an array of selected choice labels
return responseValue.map(findChoiceByLabel).filter((choiceId): choiceId is string => choiceId !== null);
// Filter out empty string sentinel used as "other" marker in multipleChoiceMulti
return responseValue
.filter((v) => v !== "")
.map(findChoiceByLabel)
.filter((choiceId): choiceId is string => choiceId !== null);
} else if (typeof responseValue === "string") {
// Single choice case - response is a single choice label
const choiceId = findChoiceByLabel(responseValue);
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "Dieses Quartal",
"this_year": "Dieses Jahr",
"time_to_complete": "Zeit zur Fertigstellung",
"ttc_survey_tooltip": "Durchschnittliche Zeit zum Abschließen der Umfrage.",
"ttc_tooltip": "Durchschnittliche Zeit zum Beantworten der Frage.",
"unknown_question_type": "Unbekannter Fragetyp",
"use_personal_links": "Nutze persönliche Links",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "This quarter",
"this_year": "This year",
"time_to_complete": "Time to Complete",
"ttc_survey_tooltip": "Average time to complete the survey.",
"ttc_tooltip": "Average time to complete the question.",
"unknown_question_type": "Unknown Question Type",
"use_personal_links": "Use personal links",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "Este trimestre",
"this_year": "Este año",
"time_to_complete": "Tiempo para completar",
"ttc_survey_tooltip": "Tiempo promedio para completar la encuesta.",
"ttc_tooltip": "Tiempo medio para completar la pregunta.",
"unknown_question_type": "Tipo de pregunta desconocido",
"use_personal_links": "Usar enlaces personales",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "Ce trimestre",
"this_year": "Cette année",
"time_to_complete": "Temps à compléter",
"ttc_survey_tooltip": "Temps moyen pour compléter le sondage.",
"ttc_tooltip": "Temps moyen pour compléter la question.",
"unknown_question_type": "Type de question inconnu",
"use_personal_links": "Utilisez des liens personnels",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "Ez a negyedév",
"this_year": "Ez az év",
"time_to_complete": "Kitöltéshez szükséges idő",
"ttc_survey_tooltip": "A felmérés kitöltésének átlagos ideje.",
"ttc_tooltip": "A kérdés megválaszolásának átlagos ideje.",
"unknown_question_type": "Ismeretlen kérdéstípus",
"use_personal_links": "Személyes hivatkozások használata",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "今四半期",
"this_year": "今年",
"time_to_complete": "完了までの時間",
"ttc_survey_tooltip": "アンケートの平均完了時間。",
"ttc_tooltip": "フォームを完了するまでの平均時間。",
"unknown_question_type": "不明な質問の種類",
"use_personal_links": "個人リンクを使用",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "Dit kwartaal",
"this_year": "Dit jaar",
"time_to_complete": "Tijd om te voltooien",
"ttc_survey_tooltip": "Gemiddelde tijd om de enquête te voltooien.",
"ttc_tooltip": "Gemiddelde tijd om de vraag te beantwoorden.",
"unknown_question_type": "Onbekend vraagtype",
"use_personal_links": "Gebruik persoonlijke links",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "Este trimestre",
"this_year": "Este ano",
"time_to_complete": "Tempo para Concluir",
"ttc_survey_tooltip": "Tempo médio para completar a pesquisa.",
"ttc_tooltip": "Tempo médio para completar a pergunta.",
"unknown_question_type": "Tipo de pergunta desconhecido",
"use_personal_links": "Use links pessoais",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "Este trimestre",
"this_year": "Este ano",
"time_to_complete": "Tempo para Concluir",
"ttc_survey_tooltip": "Tempo médio para completar o inquérito.",
"ttc_tooltip": "Tempo médio para concluir a pergunta.",
"unknown_question_type": "Tipo de Pergunta Desconhecido",
"use_personal_links": "Utilize links pessoais",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "Trimestrul acesta",
"this_year": "Anul acesta",
"time_to_complete": "Timp de finalizare",
"ttc_survey_tooltip": "Timpul mediu de finalizare a sondajului.",
"ttc_tooltip": "Timp mediu pentru a completa întrebarea.",
"unknown_question_type": "Tip de întrebare necunoscut",
"use_personal_links": "Folosește linkuri personale",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "В этом квартале",
"this_year": "В этом году",
"time_to_complete": "Время на прохождение",
"ttc_survey_tooltip": "Среднее время прохождения опроса.",
"ttc_tooltip": "Среднее время на ответ на вопрос.",
"unknown_question_type": "Неизвестный тип вопроса",
"use_personal_links": "Использовать персональные ссылки",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "Detta kvartal",
"this_year": "Detta år",
"time_to_complete": "Tid att slutföra",
"ttc_survey_tooltip": "Genomsnittlig tid för att slutföra enkäten.",
"ttc_tooltip": "Genomsnittlig tid för att slutföra frågan.",
"unknown_question_type": "Okänd frågetyp",
"use_personal_links": "Använd personliga länkar",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "本季度",
"this_year": "今年",
"time_to_complete": "完成时间",
"ttc_survey_tooltip": "完成调查的平均时间。",
"ttc_tooltip": "完成 本 问题 的 平均 时间",
"unknown_question_type": "未知 问题 类型",
"use_personal_links": "使用 个人 链接",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "本季",
"this_year": "今年",
"time_to_complete": "完成時間",
"ttc_survey_tooltip": "完成問卷調查的平均時間。",
"ttc_tooltip": "完成 問題 的 平均 時間。",
"unknown_question_type": "未知的問題類型",
"use_personal_links": "使用 個人 連結",
@@ -163,10 +163,12 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
/>
);
} else if (Array.isArray(responseData)) {
const itemsArray = responseData.map((choice) => {
const choiceId = getChoiceIdByValue(choice, element);
return { value: choice, id: choiceId };
});
const itemsArray = responseData
.filter((choice) => choice !== "")
.map((choice) => {
const choiceId = getChoiceIdByValue(choice, element);
return { value: choice, id: choiceId };
});
return (
<>
{element.type === TSurveyElementTypeEnum.Ranking ? (
+5
View File
@@ -166,6 +166,11 @@ async function handleOrganizationCreation(ctx: ActionClientCtx, user: TCreatedUs
});
}
capturePostHogEvent(user.id, "organization_created", {
organization_id: organization.id,
is_first_org: true,
});
await updateUser(user.id, {
notificationSettings: {
...user.notificationSettings,
@@ -280,6 +280,7 @@ function DropdownVariant({
placeholder={otherOptionPlaceholder}
disabled={disabled}
aria-required={required}
aria-invalid={Boolean(errorMessage)}
dir={dir}
className="mt-2 w-full"
/>
@@ -401,6 +402,7 @@ function ListVariant({
placeholder={otherOptionPlaceholder}
disabled={disabled}
aria-required={required}
aria-invalid={Boolean(errorMessage)}
dir={dir}
className="mt-2 w-full"
ref={otherInputRef}
@@ -272,6 +272,7 @@ function SingleSelect({
onChange={handleOtherInputChange}
placeholder={otherOptionPlaceholder}
disabled={disabled}
aria-invalid={Boolean(errorMessage)}
dir={dir}
className="mt-2 w-full"
/>
@@ -334,6 +335,7 @@ function SingleSelect({
placeholder={otherOptionPlaceholder}
disabled={disabled}
aria-required={required}
aria-invalid={Boolean(errorMessage)}
dir={dir}
className="mt-2 w-full"
/>
+1
View File
@@ -12,6 +12,7 @@
"da",
"de",
"es",
"et",
"fr",
"hi",
"hu",
+1
View File
@@ -34,6 +34,7 @@ checksums:
common/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
common/the_servers_cannot_be_reached_at_the_moment: f8adbeccac69f9230a55b5b3af52b081
common/they_will_be_redirected_immediately: 936bc99cb575cba95ea8f04d82bb353b
common/welcome_video: 1f87e84c0a563c2522eef5cb71a1f95c
common/your_feedback_is_stuck: db2b6aba26723b01aee0fc918d3ca052
errors/all_options_must_be_ranked: 360a2edff623496f7047907bad115ea1
errors/all_rows_must_be_answered: 295f41a0ef04cbb3491c798053c61abd
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "شروط الخدمة",
"the_servers_cannot_be_reached_at_the_moment": "لا يمكن الوصول إلى الخوادم في الوقت الحالي.",
"they_will_be_redirected_immediately": "سيتم إعادة توجيههم فورًا",
"welcome_video": "فيديو بطاقة الترحيب",
"your_feedback_is_stuck": "تعليقك عالق :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Vilkår for brug",
"the_servers_cannot_be_reached_at_the_moment": "Serverne kan ikke kontaktes lige nu.",
"they_will_be_redirected_immediately": "De bliver straks omdirigeret",
"welcome_video": "Velkomstkortvideo",
"your_feedback_is_stuck": "Din feedback sidder fast :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"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",
"welcome_video": "Willkommenskarten-Video",
"your_feedback_is_stuck": "Ihr Feedback steckt fest :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"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",
"welcome_video": "Welcome Card video",
"your_feedback_is_stuck": "Your feedback is stuck :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"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",
"welcome_video": "Vídeo de la tarjeta de bienvenida",
"your_feedback_is_stuck": "Tu feedback está atascado :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Teenusetingimused",
"the_servers_cannot_be_reached_at_the_moment": "Serveritega ei saa hetkel ühendust.",
"they_will_be_redirected_immediately": "Nad suunatakse kohe ümber",
"welcome_video": "Tervituskaardi video",
"your_feedback_is_stuck": "Sinu tagasiside on kinni jäänud :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"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",
"welcome_video": "Vidéo de la carte de bienvenue",
"your_feedback_is_stuck": "Votre feedback est bloqué :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "सेवा की शर्तें",
"the_servers_cannot_be_reached_at_the_moment": "इस समय सर्वर तक पहुंचा नहीं जा सकता है।",
"they_will_be_redirected_immediately": "उन्हें तुरंत रीडायरेक्ट किया जाएगा",
"welcome_video": "स्वागत कार्ड वीडियो",
"your_feedback_is_stuck": "आपकी प्रतिक्रिया अटक गई है :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Használati feltételek",
"the_servers_cannot_be_reached_at_the_moment": "Jelenleg nem lehet elérni a kiszolgálókat.",
"they_will_be_redirected_immediately": "Azonnal át lesznek irányítva",
"welcome_video": "Üdvözlő kártya videó",
"your_feedback_is_stuck": "A visszajelzése elakadt :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"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",
"welcome_video": "Video della scheda di benvenuto",
"your_feedback_is_stuck": "Il tuo feedback è bloccato :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "利用規約",
"the_servers_cannot_be_reached_at_the_moment": "現在サーバーに接続できません。",
"they_will_be_redirected_immediately": "すぐにリダイレクトされます",
"welcome_video": "ウェルカムカード動画",
"your_feedback_is_stuck": "フィードバックが送信できません :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"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",
"welcome_video": "Welkomstkaart video",
"your_feedback_is_stuck": "Je feedback blijft hangen :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"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",
"welcome_video": "Vídeo do Cartão de Boas-vindas",
"your_feedback_is_stuck": "Seu feedback está preso :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Termeni și condiții",
"the_servers_cannot_be_reached_at_the_moment": "Serverele nu pot fi accesate momentan.",
"they_will_be_redirected_immediately": "Vor fi redirecționați imediat",
"welcome_video": "Videoclip Card de bun venit",
"your_feedback_is_stuck": "Feedback-ul tău este blocat :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Условия использования",
"the_servers_cannot_be_reached_at_the_moment": "Сервера в данный момент недоступны.",
"they_will_be_redirected_immediately": "Они будут немедленно перенаправлены",
"welcome_video": "Видео приветственной карточки",
"your_feedback_is_stuck": "Ваш отзыв застрял :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Användarvillkor",
"the_servers_cannot_be_reached_at_the_moment": "Servrarna kan inte nås för tillfället.",
"they_will_be_redirected_immediately": "De kommer att omdirigeras omedelbart",
"welcome_video": "Välkomstkortvideo",
"your_feedback_is_stuck": "Din feedback fastnade :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Xizmat ko'rsatish shartlari",
"the_servers_cannot_be_reached_at_the_moment": "Hozirda serverlarga ulanish imkoni yo'q.",
"they_will_be_redirected_immediately": "Ular darhol yo'naltiriladi",
"welcome_video": "Xush kelibsiz kartasi videosi",
"your_feedback_is_stuck": "Sizning fikr-mulohazangiz qotib qoldi :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "服务条款",
"the_servers_cannot_be_reached_at_the_moment": "目前无法连接到服务器。",
"they_will_be_redirected_immediately": "他们将立即被重定向",
"welcome_video": "欢迎卡片视频",
"your_feedback_is_stuck": "您的反馈卡住了 :("
},
"errors": {
+2 -1
View File
@@ -49,7 +49,8 @@
"i18next-icu": "2.4.3",
"isomorphic-dompurify": "3.1.0",
"preact": "10.29.0",
"react-i18next": "16.5.8"
"react-i18next": "16.5.8",
"tailwind-merge": "3.5.0"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
@@ -169,8 +169,7 @@ export function MultipleChoiceMultiElement({
setOtherValue(newOtherValue);
const baseLabels = getNormalizedSelectedLabels();
const nextValue = [...baseLabels, ""];
if (newOtherValue.trim()) nextValue.push(newOtherValue);
const nextValue = [...baseLabels, newOtherValue];
onChange({ [element.id]: nextValue });
};
@@ -227,8 +226,7 @@ export function MultipleChoiceMultiElement({
});
if (isOtherNowSelected) {
nextLabels.push("");
if (otherValue.trim()) nextLabels.push(otherValue);
nextLabels.push(otherValue);
} else if (otherValue) {
// If other was deselected, clear any stale other value
setOtherValue("");
@@ -23,15 +23,16 @@ interface ElementMediaProps {
imgUrl?: string;
videoUrl?: string;
altText?: string;
className?: string;
}
export function ElementMedia({ imgUrl, videoUrl, altText = "Image" }: ElementMediaProps) {
export function ElementMedia({ imgUrl, videoUrl, altText = "Image", className }: ElementMediaProps) {
const { t } = useTranslation();
const videoUrlWithParams = videoUrl ? getVideoUrlWithParams(videoUrl) : undefined;
const [isLoading, setIsLoading] = useState(true);
return (
<div className="group/image relative mb-6 block min-h-40 rounded-md">
<div className={cn("group/image relative mb-6 block min-h-40 rounded-md", className)}>
{isLoading ? (
<div className="absolute inset-auto flex h-full w-full animate-pulse items-center justify-center rounded-md bg-slate-200" />
) : null}
@@ -539,8 +539,8 @@ export function Survey({
// --- Warn before leaving mid-survey or with unsent offline responses ---
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
// Warn if user has started answering but hasn't finished the survey
if (history.length > 0 && !isSurveyFinished) {
// Warn if user has started answering but hasn't finished the survey (only when offline support is active)
if (offlinePersistEnabled && history.length > 0 && !isSurveyFinished) {
e.preventDefault();
return;
}
@@ -147,8 +147,10 @@ export function WelcomeCard({
return (
<ScrollableContainer fullSizeCards={fullSizeCards}>
<div>
{fileUrl || videoUrl ? (
<ElementMedia imgUrl={fileUrl} videoUrl={videoUrl} altText={t("common.company_logo")} />
{fileUrl ? (
<ElementMedia imgUrl={fileUrl} altText={t("common.company_logo")} className="mb-8 min-h-0 w-1/4" />
) : videoUrl ? (
<ElementMedia videoUrl={videoUrl} altText={t("common.welcome_video")} />
) : null}
<Headline
+3 -2
View File
@@ -1,3 +1,4 @@
import { twMerge } from "tailwind-merge";
import { type Result, err, ok, wrapThrowsAsync } from "@formbricks/types/error-handlers";
import { type ApiErrorResponse } from "@formbricks/types/errors";
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
@@ -11,8 +12,8 @@ import { type TSurveyElement, type TSurveyElementChoice } from "@formbricks/type
import { type TShuffleOption } from "@formbricks/types/surveys/types";
import { ApiResponse, ApiSuccessResponse } from "@/types/api";
export const cn = (...classes: string[]) => {
return classes.filter(Boolean).join(" ");
export const cn = (...classes: (string | undefined)[]) => {
return twMerge(classes.filter(Boolean).join(" "));
};
export const getSecureRandom = (): number => {
@@ -154,6 +154,17 @@ const checkRequiredField = (
return createRequiredError(t);
}
// For multi-select: if "other" is selected (sentinel ""), require the other text to be non-empty
if (element.type === TSurveyElementTypeEnum.MultipleChoiceMulti && Array.isArray(value)) {
const sentinelIndex = value.indexOf("");
if (sentinelIndex !== -1) {
const otherText = value[sentinelIndex + 1];
if (!otherText || (typeof otherText === "string" && otherText.trim() === "")) {
return createRequiredError(t);
}
}
}
return null;
};
+3
View File
@@ -990,6 +990,9 @@ importers:
react-i18next:
specifier: 16.5.8
version: 16.5.8(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
tailwind-merge:
specifier: 3.5.0
version: 3.5.0
devDependencies:
'@formbricks/config-typescript':
specifier: workspace:*