mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 02:46:46 -05:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e823e10f9a | |||
| f5c3212b2c | |||
| 2d66fc6987 | |||
| 652970003d | |||
| a8b5e286b6 |
+3
-1
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
+4
-4
@@ -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", () => {
|
||||
|
||||
+41
-8
@@ -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),
|
||||
});
|
||||
};
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2127,6 +2127,7 @@
|
||||
"this_quarter": "今四半期",
|
||||
"this_year": "今年",
|
||||
"time_to_complete": "完了までの時間",
|
||||
"ttc_survey_tooltip": "アンケートの平均完了時間。",
|
||||
"ttc_tooltip": "フォームを完了するまでの平均時間。",
|
||||
"unknown_question_type": "不明な質問の種類",
|
||||
"use_personal_links": "個人リンクを使用",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2127,6 +2127,7 @@
|
||||
"this_quarter": "В этом квартале",
|
||||
"this_year": "В этом году",
|
||||
"time_to_complete": "Время на прохождение",
|
||||
"ttc_survey_tooltip": "Среднее время прохождения опроса.",
|
||||
"ttc_tooltip": "Среднее время на ответ на вопрос.",
|
||||
"unknown_question_type": "Неизвестный тип вопроса",
|
||||
"use_personal_links": "Использовать персональные ссылки",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2127,6 +2127,7 @@
|
||||
"this_quarter": "本季度",
|
||||
"this_year": "今年",
|
||||
"time_to_complete": "完成时间",
|
||||
"ttc_survey_tooltip": "完成调查的平均时间。",
|
||||
"ttc_tooltip": "完成 本 问题 的 平均 时间",
|
||||
"unknown_question_type": "未知 问题 类型",
|
||||
"use_personal_links": "使用 个人 链接",
|
||||
|
||||
@@ -2127,6 +2127,7 @@
|
||||
"this_quarter": "本季",
|
||||
"this_year": "今年",
|
||||
"time_to_complete": "完成時間",
|
||||
"ttc_survey_tooltip": "完成問卷調查的平均時間。",
|
||||
"ttc_tooltip": "完成 問題 的 平均 時間。",
|
||||
"unknown_question_type": "未知的問題類型",
|
||||
"use_personal_links": "使用 個人 連結",
|
||||
|
||||
+6
-4
@@ -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 ? (
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"da",
|
||||
"de",
|
||||
"es",
|
||||
"et",
|
||||
"fr",
|
||||
"hi",
|
||||
"hu",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Generated
+3
@@ -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:*
|
||||
|
||||
Reference in New Issue
Block a user