Compare commits

...

7 Commits

Author SHA1 Message Date
Johannes
843439d9b4 spacing 2025-12-20 07:16:51 +01:00
Johannes
763ab1b679 tweaks and update docs nav 2025-12-20 07:15:21 +01:00
harshsbhat
ea67ced8a0 docs: reorder 2025-12-19 11:12:36 +05:30
harshsbhat
1a29310ac0 docs: Link vs In app surveys 2025-12-19 11:05:10 +05:30
Johannes
3ce07edf43 chore: replacing intercom with chatwoot (#6980)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-16 16:16:09 +00:00
Johannes
0f34d9cc5f feat: standardize URL prefilling with option ID support and MQB support (#6970)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-16 10:09:47 +00:00
Matti Nannt
e9f800f017 fix: prepare pnpm in runner stage for airgapped deployments (#6925) 2025-12-15 13:30:55 +00:00
34 changed files with 2648 additions and 2062 deletions

View File

@@ -189,8 +189,9 @@ REDIS_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL:
# INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY=
# Chatwoot
# CHATWOOT_BASE_URL=
# CHATWOOT_WEBSITE_TOKEN=
# Enable Prometheus metrics
# PROMETHEUS_ENABLED=

View File

@@ -73,8 +73,8 @@ RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_ver
#
FROM base AS runner
RUN npm install --ignore-scripts -g corepack@latest
RUN corepack enable
RUN npm install --ignore-scripts -g corepack@latest && \
corepack enable
RUN apk add --no-cache curl \
&& apk add --no-cache supercronic \
@@ -134,12 +134,13 @@ EXPOSE 3000
ENV HOSTNAME="0.0.0.0"
USER nextjs
# Prepare volume for uploads
RUN mkdir -p /home/nextjs/apps/web/uploads/
VOLUME /home/nextjs/apps/web/uploads/
# Prepare pnpm as the nextjs user to ensure it's available at runtime
# Prepare volumes for uploads and SAML connections
RUN corepack prepare pnpm@9.15.9 --activate && \
mkdir -p /home/nextjs/apps/web/uploads/ && \
mkdir -p /home/nextjs/apps/web/saml-connection
# Prepare volume for SAML preloaded connection
RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/uploads/
VOLUME /home/nextjs/apps/web/saml-connection
CMD ["/home/nextjs/start.sh"]

View File

@@ -1,5 +1,6 @@
import { getServerSession } from "next-auth";
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { ChatwootWidget } from "@/app/chatwoot/ChatwootWidget";
import { CHATWOOT_BASE_URL, CHATWOOT_WEBSITE_TOKEN, IS_CHATWOOT_CONFIGURED } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
@@ -18,7 +19,15 @@ const AppLayout = async ({ children }) => {
return (
<>
<NoMobileOverlay />
<IntercomClientWrapper user={user} />
{IS_CHATWOOT_CONFIGURED && (
<ChatwootWidget
userEmail={user?.email}
userName={user?.name}
userId={user?.id}
chatwootWebsiteToken={CHATWOOT_WEBSITE_TOKEN}
chatwootBaseUrl={CHATWOOT_BASE_URL}
/>
)}
<ToasterClient />
{children}
</>

View File

@@ -1,11 +1,9 @@
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
const AppLayout = async ({ children }) => {
return (
<>
<NoMobileOverlay />
<IntercomClientWrapper />
{children}
</>
);

View File

@@ -0,0 +1,97 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
interface ChatwootWidgetProps {
chatwootBaseUrl: string;
chatwootWebsiteToken?: string;
userEmail?: string | null;
userName?: string | null;
userId?: string | null;
}
const CHATWOOT_SCRIPT_ID = "chatwoot-script";
export const ChatwootWidget = ({
userEmail,
userName,
userId,
chatwootWebsiteToken,
chatwootBaseUrl,
}: ChatwootWidgetProps) => {
const userSetRef = useRef(false);
const setUserInfo = useCallback(() => {
const $chatwoot = (
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot;
if (userId && $chatwoot && !userSetRef.current) {
$chatwoot.setUser(userId, {
email: userEmail,
name: userName,
});
userSetRef.current = true;
}
}, [userId, userEmail, userName]);
useEffect(() => {
if (!chatwootWebsiteToken) return;
const existingScript = document.getElementById(CHATWOOT_SCRIPT_ID);
if (existingScript) return;
const script = document.createElement("script");
script.src = `${chatwootBaseUrl}/packs/js/sdk.js`;
script.id = CHATWOOT_SCRIPT_ID;
script.async = true;
script.onload = () => {
(
globalThis as unknown as {
chatwootSDK: { run: (options: { websiteToken: string; baseUrl: string }) => void };
}
).chatwootSDK?.run({
websiteToken: chatwootWebsiteToken,
baseUrl: chatwootBaseUrl,
});
};
document.head.appendChild(script);
const handleChatwootReady = () => setUserInfo();
globalThis.addEventListener("chatwoot:ready", handleChatwootReady);
// Check if Chatwoot is already ready
if (
(
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot
) {
setUserInfo();
}
return () => {
globalThis.removeEventListener("chatwoot:ready", handleChatwootReady);
const $chatwoot = (globalThis as unknown as { $chatwoot: { reset: () => void } }).$chatwoot;
if ($chatwoot) {
$chatwoot.reset();
}
const scriptElement = document.getElementById(CHATWOOT_SCRIPT_ID);
scriptElement?.remove();
userSetRef.current = false;
};
}, [chatwootBaseUrl, chatwootWebsiteToken, userId, userEmail, userName, setUserInfo]);
return null;
};

View File

@@ -1,67 +0,0 @@
"use client";
import Intercom from "@intercom/messenger-js-sdk";
import { useCallback, useEffect } from "react";
import { TUser } from "@formbricks/types/user";
interface IntercomClientProps {
isIntercomConfigured: boolean;
intercomUserHash?: string;
user?: TUser | null;
intercomAppId?: string;
}
export const IntercomClient = ({
user,
intercomUserHash,
isIntercomConfigured,
intercomAppId,
}: IntercomClientProps) => {
const initializeIntercom = useCallback(() => {
let initParams = {};
if (user && intercomUserHash) {
const { id, name, email, createdAt } = user;
initParams = {
user_id: id,
user_hash: intercomUserHash,
name,
email,
created_at: createdAt ? Math.floor(createdAt.getTime() / 1000) : undefined,
};
}
Intercom({
app_id: intercomAppId!,
...initParams,
});
}, [user, intercomUserHash, intercomAppId]);
useEffect(() => {
try {
if (isIntercomConfigured) {
if (!intercomAppId) {
throw new Error("Intercom app ID is required");
}
if (user && !intercomUserHash) {
throw new Error("Intercom user hash is required");
}
initializeIntercom();
}
return () => {
// Shutdown Intercom when component unmounts
if (typeof window !== "undefined" && window.Intercom) {
window.Intercom("shutdown");
}
};
} catch (error) {
console.error("Failed to initialize Intercom:", error);
}
}, [isIntercomConfigured, initializeIntercom, intercomAppId, intercomUserHash, user]);
return null;
};

View File

@@ -1,26 +0,0 @@
import { createHmac } from "crypto";
import type { TUser } from "@formbricks/types/user";
import { INTERCOM_APP_ID, INTERCOM_SECRET_KEY, IS_INTERCOM_CONFIGURED } from "@/lib/constants";
import { IntercomClient } from "./IntercomClient";
interface IntercomClientWrapperProps {
user?: TUser | null;
}
export const IntercomClientWrapper = ({ user }: IntercomClientWrapperProps) => {
let intercomUserHash: string | undefined;
if (user) {
const secretKey = INTERCOM_SECRET_KEY;
if (secretKey) {
intercomUserHash = createHmac("sha256", secretKey).update(user.id).digest("hex");
}
}
return (
<IntercomClient
isIntercomConfigured={IS_INTERCOM_CONFIGURED}
user={user}
intercomAppId={INTERCOM_APP_ID}
intercomUserHash={intercomUserHash}
/>
);
};

View File

@@ -215,9 +215,9 @@ export const BILLING_LIMITS = {
},
} as const;
export const INTERCOM_SECRET_KEY = env.INTERCOM_SECRET_KEY;
export const INTERCOM_APP_ID = env.INTERCOM_APP_ID;
export const IS_INTERCOM_CONFIGURED = Boolean(env.INTERCOM_APP_ID && INTERCOM_SECRET_KEY);
export const CHATWOOT_WEBSITE_TOKEN = env.CHATWOOT_WEBSITE_TOKEN;
export const CHATWOOT_BASE_URL = env.CHATWOOT_BASE_URL || "https://app.chatwoot.com";
export const IS_CHATWOOT_CONFIGURED = Boolean(env.CHATWOOT_WEBSITE_TOKEN);
export const TURNSTILE_SECRET_KEY = env.TURNSTILE_SECRET_KEY;
export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY;

View File

@@ -39,8 +39,8 @@ export const env = createEnv({
.or(z.string().refine((str) => str === "")),
IMPRINT_ADDRESS: z.string().optional(),
INVITE_DISABLED: z.enum(["1", "0"]).optional(),
INTERCOM_SECRET_KEY: z.string().optional(),
INTERCOM_APP_ID: z.string().optional(),
CHATWOOT_WEBSITE_TOKEN: z.string().optional(),
CHATWOOT_BASE_URL: z.string().url().optional(),
IS_FORMBRICKS_CLOUD: z.enum(["1", "0"]).optional(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error", "fatal"]).optional(),
MAIL_FROM: z.string().email().optional(),
@@ -162,7 +162,8 @@ export const env = createEnv({
IMPRINT_URL: process.env.IMPRINT_URL,
IMPRINT_ADDRESS: process.env.IMPRINT_ADDRESS,
INVITE_DISABLED: process.env.INVITE_DISABLED,
INTERCOM_SECRET_KEY: process.env.INTERCOM_SECRET_KEY,
CHATWOOT_WEBSITE_TOKEN: process.env.CHATWOOT_WEBSITE_TOKEN,
CHATWOOT_BASE_URL: process.env.CHATWOOT_BASE_URL,
IS_FORMBRICKS_CLOUD: process.env.IS_FORMBRICKS_CLOUD,
LOG_LEVEL: process.env.LOG_LEVEL,
MAIL_FROM: process.env.MAIL_FROM,
@@ -170,7 +171,6 @@ export const env = createEnv({
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
SENTRY_DSN: process.env.SENTRY_DSN,
OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,
INTERCOM_APP_ID: process.env.INTERCOM_APP_ID,
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
NOTION_OAUTH_CLIENT_SECRET: process.env.NOTION_OAUTH_CLIENT_SECRET,
OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID,

View File

@@ -3,8 +3,8 @@
import Link from "next/link";
import { WidgetStatusIndicator } from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { WEBAPP_URL } from "@/lib/constants";
import { getActionClasses } from "@/lib/actionClass/service";
import { WEBAPP_URL } from "@/lib/constants";
import { getEnvironments } from "@/lib/environment/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";

View File

@@ -8,7 +8,7 @@ import { TResponseData } from "@formbricks/types/responses";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-wrapper";
import { getPrefillValue } from "@/modules/survey/link/lib/utils";
import { getPrefillValue } from "@/modules/survey/link/lib/prefill";
import { SurveyInline } from "@/modules/ui/components/survey";
interface SurveyClientWrapperProps {

View File

@@ -3,9 +3,9 @@ import { describe, expect, test } from "vitest";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
import { getPrefillValue } from "./utils";
import { getPrefillValue } from "./index";
describe("survey link utils", () => {
describe("prefill integration tests", () => {
const mockSurvey = {
id: "survey1",
name: "Test Survey",
@@ -76,15 +76,7 @@ describe("survey link utils", () => {
lowerLabel: { default: "Not likely" },
upperLabel: { default: "Very likely" },
},
{
id: "q7",
type: TSurveyElementTypeEnum.CTA,
headline: { default: "CTA Question" },
required: false,
buttonLabel: { default: "Click me" },
buttonExternal: false,
buttonUrl: "",
},
{
id: "q8",
type: TSurveyElementTypeEnum.Consent,
@@ -162,13 +154,21 @@ describe("survey link utils", () => {
expect(result).toEqual({ q1: "Open text answer" });
});
test("validates MultipleChoiceSingle questions", () => {
test("validates MultipleChoiceSingle questions with label", () => {
const searchParams = new URLSearchParams();
searchParams.set("q2", "Option 1");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q2: "Option 1" });
});
test("validates MultipleChoiceSingle questions with option ID", () => {
const searchParams = new URLSearchParams();
searchParams.set("q2", "c2");
const result = getPrefillValue(mockSurvey, searchParams, "default");
// Option ID is converted to label
expect(result).toEqual({ q2: "Option 2" });
});
test("invalidates MultipleChoiceSingle with non-existent option", () => {
const searchParams = new URLSearchParams();
searchParams.set("q2", "Non-existent option");
@@ -183,13 +183,29 @@ describe("survey link utils", () => {
expect(result).toEqual({ q3: "Custom answer" });
});
test("handles MultipleChoiceMulti questions", () => {
test("handles MultipleChoiceMulti questions with labels", () => {
const searchParams = new URLSearchParams();
searchParams.set("q4", "Option 4,Option 5");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
});
test("handles MultipleChoiceMulti questions with option IDs", () => {
const searchParams = new URLSearchParams();
searchParams.set("q4", "c4,c5");
const result = getPrefillValue(mockSurvey, searchParams, "default");
// Option IDs are converted to labels
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
});
test("handles MultipleChoiceMulti with mixed IDs and labels", () => {
const searchParams = new URLSearchParams();
searchParams.set("q4", "c4,Option 5");
const result = getPrefillValue(mockSurvey, searchParams, "default");
// Mixed: ID converted to label + label stays as-is
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
});
test("handles MultipleChoiceMulti with Other", () => {
const searchParams = new URLSearchParams();
searchParams.set("q5", "Option 6,Custom answer");
@@ -211,20 +227,6 @@ describe("survey link utils", () => {
expect(result).toBeUndefined();
});
test("handles CTA questions with clicked value", () => {
const searchParams = new URLSearchParams();
searchParams.set("q7", "clicked");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q7: "clicked" });
});
test("handles CTA questions with dismissed value", () => {
const searchParams = new URLSearchParams();
searchParams.set("q7", "dismissed");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q7: "" });
});
test("validates Consent questions", () => {
const searchParams = new URLSearchParams();
searchParams.set("q8", "accepted");
@@ -293,4 +295,18 @@ describe("survey link utils", () => {
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toBeUndefined();
});
test("handles whitespace in comma-separated values", () => {
const searchParams = new URLSearchParams();
searchParams.set("q4", "Option 4 , Option 5");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
});
test("ignores trailing commas in multi-select", () => {
const searchParams = new URLSearchParams();
searchParams.set("q4", "Option 4,Option 5,");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
});
});

View File

@@ -0,0 +1,73 @@
import { TResponseData } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { transformElement } from "./transformers";
import { validateElement } from "./validators";
/**
* Extract prefilled values from URL search parameters
*
* Supports prefilling for all survey element types with the following features:
* - Option ID or label matching for choice-based elements (single/multi-select, ranking, picture selection)
* - Comma-separated values for multi-select and ranking
* - Backward compatibility with label-based prefilling
*
* @param survey - The survey object containing blocks and elements
* @param searchParams - URL search parameters (e.g., from useSearchParams() or new URLSearchParams())
* @param languageId - Current language code for label matching
* @returns Object with element IDs as keys and prefilled values, or undefined if no valid prefills
*
* @example
* // Single select with option ID
* ?questionId=option-abc123
*
* // Multi-select with labels (backward compatible)
* ?questionId=Option1,Option2,Option3
*
* // Ranking with option IDs
* ?rankingId=choice-3,choice-1,choice-2
*
* // NPS question
* ?npsId=9
*
* // Multiple questions
* ?q1=answer1&q2=10&q3=option-xyz
*/
export const getPrefillValue = (
survey: TSurvey,
searchParams: URLSearchParams,
languageId: string
): TResponseData | undefined => {
const prefillData: TResponseData = {};
const elements = getElementsFromBlocks(survey.blocks);
searchParams.forEach((value, key) => {
try {
// Skip reserved parameter names
if (FORBIDDEN_IDS.includes(key)) {
return;
}
// Find matching element
const element = elements.find((el) => el.id === key);
if (!element) {
return;
}
// Validate the value for this element type (returns match data)
const validationResult = validateElement(element, value, languageId);
if (!validationResult.isValid) {
return;
}
// Transform the value using pre-matched data from validation
const transformedValue = transformElement(validationResult, value, languageId);
prefillData[element.id] = transformedValue;
} catch (error) {
// Catch any errors to prevent one bad prefill from breaking all prefills
console.error(`[Prefill] Error processing prefill for ${key}:`, error);
}
});
return Object.keys(prefillData).length > 0 ? prefillData : undefined;
};

View File

@@ -0,0 +1,94 @@
import "@testing-library/jest-dom/vitest";
import { describe, expect, test } from "vitest";
import { matchMultipleOptionsByIdOrLabel, matchOptionByIdOrLabel } from "./matchers";
describe("matchOptionByIdOrLabel", () => {
const choices = [
{ id: "choice-1", label: { en: "First", de: "Erste" } },
{ id: "choice-2", label: { en: "Second", de: "Zweite" } },
{ id: "other", label: { en: "Other", de: "Andere" } },
];
test("matches by ID", () => {
const result = matchOptionByIdOrLabel(choices, "choice-1", "en");
expect(result).toEqual(choices[0]);
});
test("matches by label in English", () => {
const result = matchOptionByIdOrLabel(choices, "First", "en");
expect(result).toEqual(choices[0]);
});
test("matches by label in German", () => {
const result = matchOptionByIdOrLabel(choices, "Zweite", "de");
expect(result).toEqual(choices[1]);
});
test("prefers ID match over label match", () => {
const choicesWithConflict = [
{ id: "First", label: { en: "Not First" } },
{ id: "choice-2", label: { en: "First" } },
];
const result = matchOptionByIdOrLabel(choicesWithConflict, "First", "en");
expect(result).toEqual(choicesWithConflict[0]); // Matches by ID, not label
});
test("returns null for no match", () => {
const result = matchOptionByIdOrLabel(choices, "NonExistent", "en");
expect(result).toBeNull();
});
test("returns null for empty string", () => {
const result = matchOptionByIdOrLabel(choices, "", "en");
expect(result).toBeNull();
});
test("handles special characters in labels", () => {
const specialChoices = [{ id: "c1", label: { en: "Option (1)" } }];
const result = matchOptionByIdOrLabel(specialChoices, "Option (1)", "en");
expect(result).toEqual(specialChoices[0]);
});
});
describe("matchMultipleOptionsByIdOrLabel", () => {
const choices = [
{ id: "choice-1", label: { en: "First" } },
{ id: "choice-2", label: { en: "Second" } },
{ id: "choice-3", label: { en: "Third" } },
];
test("matches multiple values by ID", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["choice-1", "choice-3"], "en");
expect(result).toEqual([choices[0], choices[2]]);
});
test("matches multiple values by label", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["First", "Third"], "en");
expect(result).toEqual([choices[0], choices[2]]);
});
test("matches mixed IDs and labels", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["choice-1", "Second", "choice-3"], "en");
expect(result).toEqual([choices[0], choices[1], choices[2]]);
});
test("preserves order of values", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["Third", "First", "Second"], "en");
expect(result).toEqual([choices[2], choices[0], choices[1]]);
});
test("skips non-matching values", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["First", "NonExistent", "Third"], "en");
expect(result).toEqual([choices[0], choices[2]]);
});
test("returns empty array for all non-matching values", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["NonExistent1", "NonExistent2"], "en");
expect(result).toEqual([]);
});
test("handles empty values array", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, [], "en");
expect(result).toEqual([]);
});
});

View File

@@ -0,0 +1,42 @@
import { TSurveyElementChoice } from "@formbricks/types/surveys/elements";
/**
* Match a value against element choices by ID first, then by label
* This enables both option ID-based and label-based prefilling
*
* @param choices - Array of choice objects with id and label
* @param value - Value from URL parameter (either choice ID or label text)
* @param languageCode - Current language code for label matching
* @returns Matched choice or null if no match found
*/
export const matchOptionByIdOrLabel = (
choices: TSurveyElementChoice[],
value: string,
languageCode: string
): TSurveyElementChoice | null => {
const matchById = choices.find((choice) => choice.id === value);
if (matchById) return matchById;
const matchByLabel = choices.find((choice) => choice.label[languageCode] === value);
if (matchByLabel) return matchByLabel;
return null;
};
/**
* Match multiple values against choices
* Used for multi-select and ranking elements
*
* @param choices - Array of choice objects
* @param values - Array of values from URL parameter
* @param languageCode - Current language code
* @returns Array of matched choices (preserves order)
*/
export const matchMultipleOptionsByIdOrLabel = (
choices: TSurveyElementChoice[],
values: string[],
languageCode: string
): TSurveyElementChoice[] =>
values
.map((value) => matchOptionByIdOrLabel(choices, value, languageCode))
.filter((match) => match !== null);

View File

@@ -0,0 +1,64 @@
import "@testing-library/jest-dom/vitest";
import { describe, expect, test } from "vitest";
import { parseCommaSeparated, parseNumber } from "./parsers";
describe("parseCommaSeparated", () => {
test("parses simple comma-separated values", () => {
expect(parseCommaSeparated("a,b,c")).toEqual(["a", "b", "c"]);
});
test("trims whitespace from values", () => {
expect(parseCommaSeparated("a , b , c")).toEqual(["a", "b", "c"]);
expect(parseCommaSeparated(" a, b, c ")).toEqual(["a", "b", "c"]);
});
test("filters out empty values", () => {
expect(parseCommaSeparated("a,,b")).toEqual(["a", "b"]);
expect(parseCommaSeparated("a,b,")).toEqual(["a", "b"]);
expect(parseCommaSeparated(",a,b")).toEqual(["a", "b"]);
});
test("handles empty string", () => {
expect(parseCommaSeparated("")).toEqual([]);
});
test("handles single value", () => {
expect(parseCommaSeparated("single")).toEqual(["single"]);
});
test("handles values with spaces", () => {
expect(parseCommaSeparated("First Choice,Second Choice")).toEqual(["First Choice", "Second Choice"]);
});
});
describe("parseNumber", () => {
test("parses valid integers", () => {
expect(parseNumber("5")).toBe(5);
expect(parseNumber("0")).toBe(0);
expect(parseNumber("10")).toBe(10);
});
test("parses valid floats", () => {
expect(parseNumber("5.5")).toBe(5.5);
expect(parseNumber("0.1")).toBe(0.1);
});
test("parses negative numbers", () => {
expect(parseNumber("-5")).toBe(-5);
expect(parseNumber("-5.5")).toBe(-5.5);
});
test("handles ampersand replacement", () => {
expect(parseNumber("5&5")).toBe(null); // Invalid after replacement
});
test("returns null for invalid strings", () => {
expect(parseNumber("abc")).toBeNull();
expect(parseNumber("")).toBeNull();
expect(parseNumber("5a")).toBeNull();
});
test("returns null for NaN result", () => {
expect(parseNumber("NaN")).toBeNull();
});
});

View File

@@ -0,0 +1,31 @@
/**
* Simple parsing helpers for URL parameter values
*/
/**
* Parse comma-separated values from URL parameter
* Used for multi-select and ranking elements
* Handles whitespace trimming and empty values
*/
export const parseCommaSeparated = (value: string): string[] => {
return value
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
};
/**
* Parse number from URL parameter
* Used for NPS and Rating elements
* Returns null if parsing fails
*/
export const parseNumber = (value: string): number | null => {
try {
// Handle `&` being used instead of `;` in some cases
const cleanedValue = value.replaceAll("&", ";");
const num = Number(JSON.parse(cleanedValue));
return Number.isNaN(num) ? null : num;
} catch {
return null;
}
};

View File

@@ -0,0 +1,100 @@
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { parseNumber } from "./parsers";
import {
TValidationResult,
isMultiChoiceResult,
isPictureSelectionResult,
isSingleChoiceResult,
} from "./types";
export const transformOpenText = (answer: string): string => {
return answer;
};
export const transformMultipleChoiceSingle = (
validationResult: TValidationResult,
answer: string,
language: string
): string => {
if (!isSingleChoiceResult(validationResult)) return answer;
const { matchedChoice } = validationResult;
// If we have a matched choice, return its label
if (matchedChoice) {
return matchedChoice.label[language] || answer;
}
// If no matched choice (null), it's an "other" value - return original
return answer;
};
export const transformMultipleChoiceMulti = (validationResult: TValidationResult): string[] => {
if (!isMultiChoiceResult(validationResult)) return [];
const { matched, others } = validationResult;
// Return matched choices + joined "other" values as single string
if (others.length > 0) {
return [...matched, others.join(",")];
}
return matched;
};
export const transformNPS = (answer: string): number => {
const num = parseNumber(answer);
return num ?? 0;
};
export const transformRating = (answer: string): number => {
const num = parseNumber(answer);
return num ?? 0;
};
export const transformConsent = (answer: string): string => {
if (answer === "dismissed") return "";
return answer;
};
export const transformPictureSelection = (validationResult: TValidationResult): string[] => {
if (!isPictureSelectionResult(validationResult)) return [];
return validationResult.selectedIds;
};
/**
* Main transformation dispatcher
* Routes to appropriate transformer based on element type
* Uses pre-matched data from validation result to avoid duplicate matching
*/
export const transformElement = (
validationResult: TValidationResult,
answer: string,
language: string
): string | number | string[] => {
if (!validationResult.isValid) return "";
try {
switch (validationResult.type) {
case TSurveyElementTypeEnum.OpenText:
return transformOpenText(answer);
case TSurveyElementTypeEnum.MultipleChoiceSingle:
return transformMultipleChoiceSingle(validationResult, answer, language);
case TSurveyElementTypeEnum.Consent:
return transformConsent(answer);
case TSurveyElementTypeEnum.Rating:
return transformRating(answer);
case TSurveyElementTypeEnum.NPS:
return transformNPS(answer);
case TSurveyElementTypeEnum.PictureSelection:
return transformPictureSelection(validationResult);
case TSurveyElementTypeEnum.MultipleChoiceMulti:
return transformMultipleChoiceMulti(validationResult);
default:
return "";
}
} catch {
return "";
}
};

View File

@@ -0,0 +1,62 @@
import { TSurveyElementChoice, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
type TInvalidResult = {
isValid: false;
};
// Base valid result for simple types (no match data needed)
type TSimpleValidResult = {
isValid: true;
};
// Single choice match result (MultipleChoiceSingle)
type TSingleChoiceValidResult = {
isValid: true;
matchedChoice: TSurveyElementChoice | null; // null means "other" value
};
// Multi choice match result (MultipleChoiceMulti)
type TMultiChoiceValidResult = {
isValid: true;
matched: string[]; // matched labels
others: string[]; // other text values
};
// Picture selection result (indices are already validated)
type TPictureSelectionValidResult = {
isValid: true;
selectedIds: string[];
};
// Discriminated union for all validation results
export type TValidationResult =
| (TInvalidResult & { type?: TSurveyElementTypeEnum })
| (TSimpleValidResult & {
type:
| TSurveyElementTypeEnum.OpenText
| TSurveyElementTypeEnum.NPS
| TSurveyElementTypeEnum.Rating
| TSurveyElementTypeEnum.Consent;
})
| (TSingleChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceSingle })
| (TMultiChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceMulti })
| (TPictureSelectionValidResult & { type: TSurveyElementTypeEnum.PictureSelection });
// Type guards for narrowing validation results
export const isValidResult = (result: TValidationResult): result is TValidationResult & { isValid: true } =>
result.isValid;
export const isSingleChoiceResult = (
result: TValidationResult
): result is TSingleChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceSingle } =>
result.isValid && result.type === TSurveyElementTypeEnum.MultipleChoiceSingle;
export const isMultiChoiceResult = (
result: TValidationResult
): result is TMultiChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceMulti } =>
result.isValid && result.type === TSurveyElementTypeEnum.MultipleChoiceMulti;
export const isPictureSelectionResult = (
result: TValidationResult
): result is TPictureSelectionValidResult & { type: TSurveyElementTypeEnum.PictureSelection } =>
result.isValid && result.type === TSurveyElementTypeEnum.PictureSelection;

View File

@@ -0,0 +1,228 @@
import {
TSurveyConsentElement,
TSurveyElement,
TSurveyElementTypeEnum,
TSurveyMultipleChoiceElement,
TSurveyPictureSelectionElement,
TSurveyRatingElement,
} from "@formbricks/types/surveys/elements";
import { matchOptionByIdOrLabel } from "./matchers";
import { parseCommaSeparated, parseNumber } from "./parsers";
import { TValidationResult } from "./types";
const invalid = (type?: TSurveyElementTypeEnum): TValidationResult => ({ isValid: false, type });
export const validateOpenText = (): TValidationResult => {
return { isValid: true, type: TSurveyElementTypeEnum.OpenText };
};
export const validateMultipleChoiceSingle = (
element: TSurveyMultipleChoiceElement,
answer: string,
language: string
): TValidationResult => {
if (element.type !== TSurveyElementTypeEnum.MultipleChoiceSingle) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceSingle);
}
if (!element.choices || !Array.isArray(element.choices) || element.choices.length === 0) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceSingle);
}
const hasOther = element.choices.at(-1)?.id === "other";
// Try matching by ID or label (new: supports both)
const matchedChoice = matchOptionByIdOrLabel(element.choices, answer, language);
if (matchedChoice) {
return {
isValid: true,
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
matchedChoice,
};
}
// If no match and has "other" option, accept any non-empty text as "other" value
if (hasOther) {
const trimmedAnswer = answer.trim();
if (trimmedAnswer !== "") {
return {
isValid: true,
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
matchedChoice: null, // null indicates "other" value
};
}
}
return invalid(TSurveyElementTypeEnum.MultipleChoiceSingle);
};
export const validateMultipleChoiceMulti = (
element: TSurveyMultipleChoiceElement,
answer: string,
language: string
): TValidationResult => {
if (element.type !== TSurveyElementTypeEnum.MultipleChoiceMulti) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
}
if (!element.choices || !Array.isArray(element.choices) || element.choices.length === 0) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
}
const hasOther = element.choices.at(-1)?.id === "other";
const lastChoiceLabel = hasOther ? element.choices.at(-1)?.label?.[language] : undefined;
const answerChoices = parseCommaSeparated(answer);
if (answerChoices.length === 0) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
}
// Process all answers and collect results
const matched: string[] = [];
const others: string[] = [];
let freeTextOtherCount = 0;
for (const ans of answerChoices) {
const matchedChoice = matchOptionByIdOrLabel(element.choices, ans, language);
if (matchedChoice) {
const label = matchedChoice.label[language];
if (label) {
matched.push(label);
}
continue;
}
// Check if it's the "Other" label itself
if (ans === lastChoiceLabel) {
continue;
}
// It's a free-text "other" value
if (hasOther) {
freeTextOtherCount++;
if (freeTextOtherCount > 1) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti); // Only one free-text "other" value allowed
}
others.push(ans);
} else {
// No "other" option and doesn't match any choice
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
}
}
return {
isValid: true,
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
matched,
others,
};
};
export const validateNPS = (answer: string): TValidationResult => {
const answerNumber = parseNumber(answer);
if (answerNumber === null || answerNumber < 0 || answerNumber > 10) {
return invalid(TSurveyElementTypeEnum.NPS);
}
return { isValid: true, type: TSurveyElementTypeEnum.NPS };
};
export const validateConsent = (element: TSurveyConsentElement, answer: string): TValidationResult => {
if (element.type !== TSurveyElementTypeEnum.Consent) {
return invalid(TSurveyElementTypeEnum.Consent);
}
if (element.required && answer === "dismissed") {
return invalid(TSurveyElementTypeEnum.Consent);
}
if (answer !== "accepted" && answer !== "dismissed") {
return invalid(TSurveyElementTypeEnum.Consent);
}
return { isValid: true, type: TSurveyElementTypeEnum.Consent };
};
export const validateRating = (element: TSurveyRatingElement, answer: string): TValidationResult => {
if (element.type !== TSurveyElementTypeEnum.Rating) {
return invalid(TSurveyElementTypeEnum.Rating);
}
const answerNumber = parseNumber(answer);
if (answerNumber === null || answerNumber < 1 || answerNumber > (element.range ?? 5)) {
return invalid(TSurveyElementTypeEnum.Rating);
}
return { isValid: true, type: TSurveyElementTypeEnum.Rating };
};
export const validatePictureSelection = (
element: TSurveyPictureSelectionElement,
answer: string
): TValidationResult => {
if (element.type !== TSurveyElementTypeEnum.PictureSelection) {
return invalid(TSurveyElementTypeEnum.PictureSelection);
}
if (!element.choices || !Array.isArray(element.choices) || element.choices.length === 0) {
return invalid(TSurveyElementTypeEnum.PictureSelection);
}
const answerChoices = parseCommaSeparated(answer);
const selectedIds: string[] = [];
// Validate all indices and collect selected IDs
for (const ans of answerChoices) {
const num = parseNumber(ans);
if (num === null || num < 1 || num > element.choices.length) {
return invalid(TSurveyElementTypeEnum.PictureSelection);
}
const index = num - 1;
const choice = element.choices[index];
if (choice?.id) {
selectedIds.push(choice.id);
}
}
// Apply allowMulti constraint
const finalIds = element.allowMulti ? selectedIds : selectedIds.slice(0, 1);
return {
isValid: true,
type: TSurveyElementTypeEnum.PictureSelection,
selectedIds: finalIds,
};
};
/**
* Main validation dispatcher
* Routes to appropriate validator based on element type
* Returns validation result with match data for transformers
*/
export const validateElement = (
element: TSurveyElement,
answer: string,
language: string
): TValidationResult => {
// Empty required fields are invalid
if (element.required && (!answer || answer === "")) {
return invalid(element.type);
}
try {
switch (element.type) {
case TSurveyElementTypeEnum.OpenText:
return validateOpenText();
case TSurveyElementTypeEnum.MultipleChoiceSingle:
return validateMultipleChoiceSingle(element, answer, language);
case TSurveyElementTypeEnum.MultipleChoiceMulti:
return validateMultipleChoiceMulti(element, answer, language);
case TSurveyElementTypeEnum.NPS:
return validateNPS(answer);
case TSurveyElementTypeEnum.Consent:
return validateConsent(element, answer);
case TSurveyElementTypeEnum.Rating:
return validateRating(element, answer);
case TSurveyElementTypeEnum.PictureSelection:
return validatePictureSelection(element, answer);
default:
return invalid();
}
} catch {
return invalid(element.type);
}
};

View File

@@ -1,230 +1,2 @@
import { TResponseData } from "@formbricks/types/responses";
import {
TSurveyCTAElement,
TSurveyConsentElement,
TSurveyElement,
TSurveyElementTypeEnum,
TSurveyMultipleChoiceElement,
TSurveyRatingElement,
} from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
export const getPrefillValue = (
survey: TSurvey,
searchParams: URLSearchParams,
languageId: string
): TResponseData | undefined => {
const prefillAnswer: TResponseData = {};
const questions = getElementsFromBlocks(survey.blocks);
const questionIdxMap = questions.reduce(
(acc, question, idx) => {
acc[question.id] = idx;
return acc;
},
{} as Record<string, number>
);
searchParams.forEach((value, key) => {
if (FORBIDDEN_IDS.includes(key)) return;
const questionId = key;
const questionIdx = questionIdxMap[questionId];
const question = questions[questionIdx];
const answer = value;
if (question) {
if (checkValidity(question, answer, languageId)) {
prefillAnswer[questionId] = transformAnswer(question, answer, languageId);
}
}
});
return Object.keys(prefillAnswer).length > 0 ? prefillAnswer : undefined;
};
const validateOpenText = (): boolean => {
return true;
};
const validateMultipleChoiceSingle = (
question: TSurveyMultipleChoiceElement,
answer: string,
language: string
): boolean => {
if (question.type !== TSurveyElementTypeEnum.MultipleChoiceSingle) return false;
const choices = question.choices;
const hasOther = choices[choices.length - 1].id === "other";
if (!hasOther) {
return choices.some((choice) => choice.label[language] === answer);
}
const matchesAnyChoice = choices.some((choice) => choice.label[language] === answer);
if (matchesAnyChoice) {
return true;
}
const trimmedAnswer = answer.trim();
return trimmedAnswer !== "";
};
const validateMultipleChoiceMulti = (question: TSurveyElement, answer: string, language: string): boolean => {
if (question.type !== TSurveyElementTypeEnum.MultipleChoiceMulti) return false;
const choices = (
question as TSurveyElement & { choices: Array<{ id: string; label: Record<string, string> }> }
).choices;
const hasOther = choices[choices.length - 1].id === "other";
const lastChoiceLabel = hasOther ? choices[choices.length - 1].label[language] : undefined;
const answerChoices = answer
.split(",")
.map((ans) => ans.trim())
.filter((ans) => ans !== "");
if (answerChoices.length === 0) {
return false;
}
if (!hasOther) {
return answerChoices.every((ans: string) => choices.some((choice) => choice.label[language] === ans));
}
let freeTextOtherCount = 0;
for (const ans of answerChoices) {
const matchesChoice = choices.some((choice) => choice.label[language] === ans);
if (matchesChoice) {
continue;
}
if (ans === lastChoiceLabel) {
continue;
}
freeTextOtherCount++;
if (freeTextOtherCount > 1) {
return false;
}
}
return true;
};
const validateNPS = (answer: string): boolean => {
try {
const cleanedAnswer = answer.replace(/&/g, ";");
const answerNumber = Number(JSON.parse(cleanedAnswer));
return !isNaN(answerNumber) && answerNumber >= 0 && answerNumber <= 10;
} catch {
return false;
}
};
const validateCTA = (question: TSurveyCTAElement, answer: string): boolean => {
if (question.required && answer === "dismissed") return false;
return answer === "clicked" || answer === "dismissed";
};
const validateConsent = (question: TSurveyConsentElement, answer: string): boolean => {
if (question.required && answer === "dismissed") return false;
return answer === "accepted" || answer === "dismissed";
};
const validateRating = (question: TSurveyRatingElement, answer: string): boolean => {
if (question.type !== TSurveyElementTypeEnum.Rating) return false;
const ratingQuestion = question;
try {
const cleanedAnswer = answer.replace(/&/g, ";");
const answerNumber = Number(JSON.parse(cleanedAnswer));
return answerNumber >= 1 && answerNumber <= (ratingQuestion.range ?? 5);
} catch {
return false;
}
};
const validatePictureSelection = (answer: string): boolean => {
const answerChoices = answer.split(",");
return answerChoices.every((ans: string) => !isNaN(Number(ans)));
};
const checkValidity = (question: TSurveyElement, answer: string, language: string): boolean => {
if (question.required && (!answer || answer === "")) return false;
const validators: Partial<
Record<TSurveyElementTypeEnum, (q: TSurveyElement, a: string, l: string) => boolean>
> = {
[TSurveyElementTypeEnum.OpenText]: () => validateOpenText(),
[TSurveyElementTypeEnum.MultipleChoiceSingle]: (q, a, l) =>
validateMultipleChoiceSingle(q as TSurveyMultipleChoiceElement, a, l),
[TSurveyElementTypeEnum.MultipleChoiceMulti]: (q, a, l) => validateMultipleChoiceMulti(q, a, l),
[TSurveyElementTypeEnum.NPS]: (_, a) => validateNPS(a),
[TSurveyElementTypeEnum.CTA]: (q, a) => validateCTA(q as TSurveyCTAElement, a),
[TSurveyElementTypeEnum.Consent]: (q, a) => validateConsent(q as TSurveyConsentElement, a),
[TSurveyElementTypeEnum.Rating]: (q, a) => validateRating(q as TSurveyRatingElement, a),
[TSurveyElementTypeEnum.PictureSelection]: (_, a) => validatePictureSelection(a),
};
const validator = validators[question.type];
if (!validator) return false;
try {
return validator(question, answer, language);
} catch {
return false;
}
};
const transformAnswer = (
question: TSurveyElement,
answer: string,
language: string
): string | number | string[] => {
switch (question.type) {
case TSurveyElementTypeEnum.OpenText:
case TSurveyElementTypeEnum.MultipleChoiceSingle: {
return answer;
}
case TSurveyElementTypeEnum.Consent:
case TSurveyElementTypeEnum.CTA: {
if (answer === "dismissed") return "";
return answer;
}
case TSurveyElementTypeEnum.Rating:
case TSurveyElementTypeEnum.NPS: {
const cleanedAnswer = answer.replace(/&/g, ";");
return Number(JSON.parse(cleanedAnswer));
}
case TSurveyElementTypeEnum.PictureSelection: {
const answerChoicesIdx = answer.split(",");
const answerArr: string[] = [];
answerChoicesIdx.forEach((ansIdx) => {
const choice = question.choices[Number(ansIdx) - 1];
if (choice) answerArr.push(choice.id);
});
if (question.allowMulti) return answerArr;
return answerArr.slice(0, 1);
}
case TSurveyElementTypeEnum.MultipleChoiceMulti: {
let ansArr = answer.split(",");
const hasOthers = question.choices[question.choices.length - 1].id === "other";
if (!hasOthers) return ansArr;
// answer can be "a,b,c,d" and options can be a,c,others so we are filtering out the options that are not in the options list and sending these non-existing values as a single string(representing others) like "a", "c", "b,d"
const options = question.choices.map((o) => o.label[language]);
const others = ansArr.filter((a: string) => !options.includes(a));
if (others.length > 0) ansArr = ansArr.filter((a: string) => options.includes(a));
if (others.length > 0) ansArr.push(others.join(","));
return ansArr;
}
default:
return "";
}
};
// Prefilling logic has been moved to @/modules/survey/link/lib/prefill
// This file is kept for any future utility functions

View File

@@ -61,10 +61,6 @@ const nextConfig = {
protocol: "https",
hostname: "images.unsplash.com",
},
{
protocol: "https",
hostname: "api-iam.eu.intercom.io",
},
],
},
async redirects() {
@@ -168,7 +164,7 @@ const nextConfig = {
},
{
key: "Content-Security-Policy",
value: `default-src 'self'; script-src 'self' 'unsafe-inline'${scriptSrcUnsafeEval} https://*.intercom.io https://*.intercomcdn.com https:; style-src 'self' 'unsafe-inline' https://*.intercomcdn.com https:; img-src 'self' blob: data: http://localhost:9000 https://*.intercom.io https://*.intercomcdn.com https:; font-src 'self' data: https://*.intercomcdn.com https:; connect-src 'self' http://localhost:9000 https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com https:; frame-src 'self' https://*.intercom.io https://app.cal.com https:; media-src 'self' https:; object-src 'self' data: https:; base-uri 'self'; form-action 'self'`,
value: `default-src 'self'; script-src 'self' 'unsafe-inline'${scriptSrcUnsafeEval} https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' blob: data: http://localhost:9000 https:; font-src 'self' data: https:; connect-src 'self' http://localhost:9000 https: wss:; frame-src 'self' https://app.cal.com https:; media-src 'self' https:; object-src 'self' data: https:; base-uri 'self'; form-action 'self'`,
},
{
key: "Strict-Transport-Security",

View File

@@ -36,7 +36,6 @@
"@formbricks/surveys": "workspace:*",
"@formbricks/types": "workspace:*",
"@hookform/resolvers": "5.0.1",
"@intercom/messenger-js-sdk": "0.0.14",
"@json2csv/node": "7.0.6",
"@lexical/code": "0.36.2",
"@lexical/link": "0.36.2",

View File

@@ -186,25 +186,20 @@
]
},
{
"group": "XM",
"group": "Best Practices",
"pages": [
{
"group": "Best Practices",
"icon": "lightbulb",
"pages": [
"xm-and-surveys/xm/best-practices/contact-form",
"xm-and-surveys/xm/best-practices/headless-surveys",
"xm-and-surveys/xm/best-practices/docs-feedback",
"xm-and-surveys/xm/best-practices/feature-chaser",
"xm-and-surveys/xm/best-practices/feedback-box",
"xm-and-surveys/xm/best-practices/improve-email-content",
"xm-and-surveys/xm/best-practices/interview-prompt",
"xm-and-surveys/xm/best-practices/cancel-subscription",
"xm-and-surveys/xm/best-practices/pmf-survey",
"xm-and-surveys/xm/best-practices/quiz-time",
"xm-and-surveys/xm/best-practices/improve-trial-cr"
]
}
"xm-and-surveys/xm/best-practices/understanding-survey-types",
"xm-and-surveys/xm/best-practices/contact-form",
"xm-and-surveys/xm/best-practices/headless-surveys",
"xm-and-surveys/xm/best-practices/docs-feedback",
"xm-and-surveys/xm/best-practices/feature-chaser",
"xm-and-surveys/xm/best-practices/feedback-box",
"xm-and-surveys/xm/best-practices/improve-email-content",
"xm-and-surveys/xm/best-practices/interview-prompt",
"xm-and-surveys/xm/best-practices/cancel-subscription",
"xm-and-surveys/xm/best-practices/pmf-survey",
"xm-and-surveys/xm/best-practices/quiz-time",
"xm-and-surveys/xm/best-practices/improve-trial-cr"
]
}
],

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -91,18 +91,6 @@ Adds 'I love Formbricks' as the answer to the open text question:
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?openText_question_id=I%20love%20Formbricks
```
### CTA Question
Accepts only 'dismissed' as answer option. Due to the risk of domain abuse, this value cannot be set to 'clicked' via prefilling:
```txt CTA Question
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?cta_question_id=dismissed
```
<Note>
Due to the risk of domain abuse, this value cannot be set to 'clicked' via prefilling.
</Note>
### Consent Question
Adds 'accepted' as the answer to the Consent question. Alternatively, you can set it to 'dismissed' to skip the question.
@@ -115,11 +103,53 @@ https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?consent_question_id=accep
Adds index of the selected image(s) as the answer to the Picture Selection question. The index starts from 1
```txt Picture Selection Question.
```txt Picture Selection Question
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?pictureSelection_question_id=1%2C2%2C3
```
<Note>All other question types, you currently cannot prefill via the URL.</Note>
## Using Option IDs for Choice-Based Questions
All choice-based question types (Single Select, Multi Select, Picture Selection) now support prefilling with **option IDs** in addition to option labels. This is the recommended approach as it's more reliable and doesn't break when you update option text.
### Benefits of Using Option IDs
- **Stable:** Option IDs don't change when you edit the option label text
- **Language-independent:** Works across all survey languages without modification
- **Reliable:** No issues with special characters or URL encoding of complex text
### How to Find Option IDs
1. Open your survey in the Survey Editor
2. Click on the question you want to prefill
3. Open the **Advanced Settings** at the bottom of the question card
4. Each option shows its unique ID that you can copy
### Examples with Option IDs
**Single Select:**
```sh Single Select with Option ID
https://app.formbricks.com/s/surveyId?questionId=option-abc123
```
**Multi Select:**
```sh Multi Select with Option IDs
https://app.formbricks.com/s/surveyId?questionId=option-1,option-2,option-3
```
**Mixed IDs and Labels:**
You can even mix option IDs and labels in the same URL (though using IDs consistently is recommended):
```sh Mixed Approach
https://app.formbricks.com/s/surveyId?questionId=option-abc,Some%20Label,option-xyz
```
### Backward Compatibility
All existing URLs using option labels continue to work. The system tries to match by option ID first, then falls back to matching by label text if no ID match is found.
## Validation
@@ -127,13 +157,12 @@ Make sure that the answer in the URL matches the expected type for the questions
The URL validation works as follows:
- For Rating or NPS questions, the response is parsed as a number and verified if it's accepted by the schema.
- For CTA type questions, the valid values are "clicked" (main CTA) and "dismissed" (skip CTA).
- For Consent type questions, the valid values are "accepted" (consent given) and "dismissed" (consent not given).
- For Picture Selection type questions, the response is parsed as an array of numbers and verified if it's accepted by the schema.
- All other question types are strings.
- **Rating or NPS questions:** The response is parsed as a number and verified to be within the valid range.
- **Consent type questions:** Valid values are "accepted" (consent given) and "dismissed" (consent not given).
- **Picture Selection questions:** The response is parsed as comma-separated numbers (1-based indices) and verified against available choices.
- **Single/Multi Select questions:** Values can be either option IDs or exact label text. The system tries to match by option ID first, then falls back to label matching.
- **Open Text questions:** Any string value is accepted.
<Note>
If an answer is invalid, the prefilling will be ignored and the question is
presented as if not prefilled.
If an answer is invalid or doesn't match any option, the prefilling will be ignored and the question is presented as if not prefilled.
</Note>

View File

@@ -0,0 +1,134 @@
---
title: "Understanding Formbricks Survey Types"
sidebarTitle: "Understanding Survey Types"
description: "This guide explains the differences between Formbricks Link Surveys and Website & App Surveys to help you choose the right option."
icon: "road-circle-check"
---
## Quick comparison
| Feature | Link Surveys | Website & App Surveys |
|---------|--------------|----------------------|
| **Best for** | Known touchpoints | Behavior-based intercepts |
| **SDK required** | No | Yes |
| **Delivery** | Email, SMS, push, or webview | Triggered in-app by SDK |
| **Segmentation** | You control externally | Built-in via Formbricks |
| **Survey fatigue prevention** | You control externally | Automatic |
| **User identification** | [Personal Links](/xm-and-surveys/surveys/link-surveys/personal-links) | [SDK user attributes](/xm-and-surveys/surveys/website-app-surveys/user-identification) |
## Link Surveys
Also known as **invitation-based surveys**. No SDK needed. You control when and who receives the survey, often through CRMs, marketing tools, or similar platforms.
![Link survey example](/images/xm-and-surveys/xm/best-practices/link-surveys-vs-in-app-surveys/link-surveys-example.webp)
### Delivery channels
- Email
- SMS
- Push notification
- In-app webview
### URL features
- **[Hidden Fields](/xm-and-surveys/surveys/general-features/hidden-fields)**: Pass user ID, order ID, or any identifier
- **[Data prefilling](/xm-and-surveys/surveys/link-surveys/data-prefilling)**: Pre-populate answers and optionally skip prefilled questions
- **[Personal Links](/xm-and-surveys/surveys/link-surveys/personal-links)**: Generate unique survey links for individual contacts
### Webview with Embed mode
For mobile apps, you can display Link Surveys in a webview with full control over when the survey appears. Your app decides the timing—open the webview when the moment is right.
**Enable Embed mode** by appending `?embed=true` to the survey URL:
```
https://app.formbricks.com/s/<surveyId>?embed=true&userId=123
```
**Listen for events** to close the webview automatically:
```javascript
window.addEventListener("message", (event) => {
if (event.data === "formbricksSurveyCompleted") {
closeWebView();
}
});
```
This approach gives you full control:
- **When**: Your app opens the webview at the right moment (after purchase, case closed, etc.)
- **Who**: Personal links allow you to have unique links for identified contacts.
- **Display**: Survey renders inside your app
- **Dismissal**: Close the webview on completion or user exit
Learn more in the [Embed Surveys guide](/xm-and-surveys/surveys/link-surveys/embed-surveys).
### When to use
- You already decide externally who should receive which survey
- Survey timing is tied to a known event such as a click on a link or button and in **each case**, you want to show the survey
- You want fastest approach to production
- Your platform doesn't have a supported SDK
- You don't need Formbricks attribute-based targeting & survey fatigue controls
## Website & App Surveys (SDK)
Also known as **intercept surveys**. Requires SDK integration. Formbricks handles targeting, triggering, and survey fatigue.
![In-app survey example](/images/xm-and-surveys/xm/best-practices/link-surveys-vs-in-app-surveys/in-app-surveys.webp)
### In-app features
**[Action tracking](/xm-and-surveys/surveys/website-app-surveys/actions)**
Track in-app events and trigger surveys based on them:
```javascript
formbricks.track("action_name");
```
**Built-in survey cooldown**
- Global waiting time (e.g., show max 1 survey every 7 days per user)
- Per-survey overrides for high-priority surveys
**[Segment-based targeting](/xm-and-surveys/surveys/website-app-surveys/advanced-targeting)**
Target surveys based on user attributes (plan type, language, feature flags).
**In-app display**
Surveys appear inside your app as modals or slide-ins.
### When to use
- You want Formbricks to handle who sees which survey
- Surveys should trigger based on user behavior (page views, clicks, custom events)
- You want automatic survey fatigue prevention
- You need percentage-based sampling (e.g., show to 10% of users)
## Decision guide
<AccordionGroup>
<Accordion title="Do you control survey triggering externally?">
Use **Link-based Surveys**. Send the URL when the moment is right, even for in-app surveys.Pass context via Hidden Fields.
</Accordion>
<Accordion title="Should surveys trigger based on in-app behavior?">
Use **Website & App Surveys**. The SDK tracks actions and triggers surveys automatically.
</Accordion>
<Accordion title="Do you want automatic survey fatigue prevention?">
Use **Website & App Surveys**. Built-in cooldown prevents over-surveying.
</Accordion>
<Accordion title="Need the fastest path to production?">
Start with **Link Surveys**. No SDK integration needed. Add SDK later if you need behavior-based triggers.
</Accordion>
</AccordionGroup>
---
**Need help?** [Join us in GitHub Discussions](https://github.com/formbricks/formbricks/discussions)

View File

@@ -29,6 +29,7 @@ checksums:
common/ranking_items: 463f2eb500f1b42fbce6cec17612fb9a
common/respondents_will_not_see_this_card: 18c3dd44d6ff6ca2310ad196b84f30d3
common/retry: 6e44d18639560596569a1278f9c83676
common/retrying: 0cb623dbdcbf16d3680f0180ceac734c
common/select_a_date: 521e4a705800da06d091fde3e801ce02
common/select_for_ranking: e5f4e20752d1c2d852cd02dc3a0e9dd0
common/sending_responses: 184772f70cca69424eaf34f73520789f

View File

@@ -67,22 +67,11 @@ export function MultipleChoiceSingleElement({
const noneOption = useMemo(() => element.choices.find((choice) => choice.id === "none"), [element.choices]);
useEffect(() => {
if (!value) {
const prefillAnswer = new URLSearchParams(window.location.search).get(element.id);
if (
prefillAnswer &&
otherOption &&
prefillAnswer === getLocalizedValue(otherOption.label, languageCode)
) {
setOtherSelected(true);
return;
}
}
// Determine if "other" option is selected based on the value
const isOtherSelected =
value !== undefined && !elementChoices.some((choice) => choice?.label[languageCode] === value);
setOtherSelected(isOtherSelected);
}, [languageCode, otherOption, element.id, elementChoices, value]);
}, [languageCode, elementChoices, value]);
useEffect(() => {
// Scroll to the bottom of choices container and focus on 'otherSpecify' input when 'otherSelected' is true

View File

@@ -73,7 +73,8 @@ export function BlockConditional({
if (elementId !== currentElementId) {
setCurrentElementId(elementId);
}
onChange(responseData);
// Merge with existing block data to preserve other element values
onChange({ ...value, ...responseData });
};
// Handler to collect TTC values synchronously (called from element form submissions)
@@ -81,32 +82,43 @@ export function BlockConditional({
ttcCollectorRef.current[elementId] = elementTtc;
};
// Handle skipPrefilled at block level
// Handle prefilling at block level (both skipPrefilled and regular prefilling)
useEffect(() => {
if (skipPrefilled && prefilledResponseData) {
// Check if ALL elements in this block have prefilled values
const allElementsPrefilled = block.elements.every(
(element) => prefilledResponseData[element.id] !== undefined
);
if (prefilledResponseData) {
// Collect all prefilled values for elements in this block
const prefilledData: TResponseData = {};
let hasAnyPrefilled = false;
if (allElementsPrefilled) {
// Auto-populate all prefilled values
const prefilledData: TResponseData = {};
const prefilledTtc: TResponseTtc = {};
block.elements.forEach((element) => {
block.elements.forEach((element) => {
if (prefilledResponseData[element.id] !== undefined) {
prefilledData[element.id] = prefilledResponseData[element.id];
prefilledTtc[element.id] = 0; // 0 TTC for prefilled/skipped questions
});
hasAnyPrefilled = true;
}
});
// Update state with prefilled data
if (hasAnyPrefilled) {
// Apply all prefilled values in one atomic operation
onChange(prefilledData);
setTtc({ ...ttc, ...prefilledTtc });
// Auto-submit the entire block (skip to next)
setTimeout(() => {
onSubmit(prefilledData, prefilledTtc);
}, 0);
// If skipPrefilled and ALL elements are prefilled, auto-submit
if (skipPrefilled) {
const allElementsPrefilled = block.elements.every(
(element) => prefilledResponseData[element.id] !== undefined
);
if (allElementsPrefilled) {
const prefilledTtc: TResponseTtc = {};
block.elements.forEach((element) => {
prefilledTtc[element.id] = 0; // 0 TTC for prefilled/skipped questions
});
setTtc({ ...ttc, ...prefilledTtc });
// Auto-submit the entire block (skip to next)
setTimeout(() => {
onSubmit(prefilledData, prefilledTtc);
}, 0);
}
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only run once when block mounts
@@ -257,17 +269,13 @@ export function BlockConditional({
element={element}
value={value[element.id]}
onChange={(responseData) => handleElementChange(element.id, responseData)}
onBack={() => {}}
onFileUpload={onFileUpload}
languageCode={languageCode}
prefilledElementValue={prefilledResponseData?.[element.id]}
skipPrefilled={skipPrefilled}
ttc={ttc}
setTtc={setTtc}
surveyId={surveyId}
autoFocusEnabled={autoFocusEnabled && isFirstElement}
currentElementId={currentElementId}
isBackButtonHidden={true}
onOpenExternalURL={onOpenExternalURL}
dir={dir}
formRef={(ref) => {

View File

@@ -28,17 +28,13 @@ interface ElementConditionalProps {
element: TSurveyElement;
value: TResponseDataValue;
onChange: (responseData: TResponseData) => void;
onBack: () => void;
onFileUpload: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
languageCode: string;
prefilledElementValue?: TResponseDataValue;
skipPrefilled?: boolean;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
surveyId: string;
autoFocusEnabled: boolean;
currentElementId: string;
isBackButtonHidden: boolean;
onOpenExternalURL?: (url: string) => void | Promise<void>;
dir?: "ltr" | "rtl" | "auto";
formRef?: (ref: HTMLFormElement | null) => void; // Callback to expose the form element
@@ -50,8 +46,6 @@ export function ElementConditional({
value,
onChange,
languageCode,
prefilledElementValue,
skipPrefilled,
ttc,
setTtc,
surveyId,
@@ -100,15 +94,6 @@ export function ElementConditional({
.filter((id): id is TSurveyElementChoice["id"] => id !== undefined);
};
useEffect(() => {
if (value === undefined && (prefilledElementValue || prefilledElementValue === "")) {
if (!skipPrefilled) {
onChange({ [element.id]: prefilledElementValue });
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- we want to run this only once when the element renders for the first time
}, []);
const isRecognizedType = Object.values(TSurveyElementTypeEnum).includes(element.type);
useEffect(() => {

3135
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -146,8 +146,8 @@
"IMPRINT_ADDRESS",
"INVITE_DISABLED",
"IS_FORMBRICKS_CLOUD",
"INTERCOM_APP_ID",
"INTERCOM_SECRET_KEY",
"CHATWOOT_WEBSITE_TOKEN",
"CHATWOOT_BASE_URL",
"LOG_LEVEL",
"MAIL_FROM",
"MAIL_FROM_NAME",