Compare commits

..

7 Commits

Author SHA1 Message Date
Dhruwang
8d11dea042 fix: build 2026-02-24 19:15:00 +05:30
Dhruwang
0268c769bc fix: build 2026-02-24 19:05:19 +05:30
Dhruwang
d1e0253a70 fix: contacts page crashing 2026-02-24 18:53:21 +05:30
Dhruwang
10afe53a5d Merge branch 'main' of https://github.com/formbricks/formbricks into fix-translatable-strings 2026-02-24 18:45:48 +05:30
Dhruwang
1b70956451 generated transaltions for other languages 2026-02-24 18:45:08 +05:30
Balázs Úr
af7b83750c add missing import 2026-02-19 15:00:27 +01:00
Balázs Úr
44c53ff774 fix: made Contact's page titles and table headers translatable 2026-02-19 14:25:57 +01:00
38 changed files with 374 additions and 164 deletions

View File

@@ -101,9 +101,6 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
# Create packages/database directory structure with proper ownership for runtime migrations
RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma

View File

@@ -81,7 +81,7 @@ export const OrganizationBreadcrumb = ({
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort organizations by name
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
setOrganizations(sorted);
} else {
// Handle server errors or validation errors

View File

@@ -82,7 +82,7 @@ export const ProjectBreadcrumb = ({
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort projects by name
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
setProjects(sorted);
} else {
// Handle server errors or validation errors

View File

@@ -352,7 +352,7 @@ export const AnonymousLinksTab = ({
},
{
title: t("environments.surveys.share.anonymous_links.custom_start_point"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-block",
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
},
]}
/>

View File

@@ -241,7 +241,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<PopoverTriggerButton isOpen={isOpen}>
{t("common.filter")} <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
Filter <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
</PopoverTriggerButton>
</PopoverTrigger>
<PopoverContent
@@ -329,7 +329,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
</div>
{i !== filterValue.filter.length - 1 && (
<div className="my-4 flex items-center">
<p className="mr-4 font-semibold text-slate-800">{t("common.and")}</p>
<p className="mr-4 font-semibold text-slate-800">and</p>
<hr className="w-full text-slate-600" />
</div>
)}

View File

@@ -6,7 +6,7 @@ import {
} from "@formbricks/types/integration/slack";
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
@@ -56,7 +56,6 @@ export const GET = withV1ApiWrapper({
code,
client_id: SLACK_CLIENT_ID,
client_secret: SLACK_CLIENT_SECRET,
redirect_uri: SLACK_REDIRECT_URI,
};
const formBody: string[] = [];
for (const property in formData) {

View File

@@ -63,8 +63,7 @@ export const INVITE_DISABLED = env.INVITE_DISABLED === "1";
export const SLACK_CLIENT_SECRET = env.SLACK_CLIENT_SECRET;
export const SLACK_CLIENT_ID = env.SLACK_CLIENT_ID;
export const SLACK_REDIRECT_URI = `${WEBAPP_URL}/api/v1/integrations/slack/callback`;
export const SLACK_AUTH_URL = `https://slack.com/oauth/v2/authorize?client_id=${env.SLACK_CLIENT_ID}&scope=channels:read,chat:write,chat:write.public,chat:write.customize,groups:read&redirect_uri=${SLACK_REDIRECT_URI}`;
export const SLACK_AUTH_URL = `https://slack.com/oauth/v2/authorize?client_id=${env.SLACK_CLIENT_ID}&scope=channels:read,chat:write,chat:write.public,chat:write.customize,groups:read`;
export const GOOGLE_SHEETS_CLIENT_ID = env.GOOGLE_SHEETS_CLIENT_ID;
export const GOOGLE_SHEETS_CLIENT_SECRET = env.GOOGLE_SHEETS_CLIENT_SECRET;

View File

@@ -225,7 +225,6 @@
"failed_to_copy_to_clipboard": "Fehler beim Kopieren in die Zwischenablage",
"failed_to_load_organizations": "Fehler beim Laden der Organisationen",
"failed_to_load_workspaces": "Projekte konnten nicht geladen werden",
"filter": "Filter",
"finish": "Fertigstellen",
"first_name": "Vorname",
"follow_these": "Folge diesen",

View File

@@ -225,7 +225,6 @@
"failed_to_copy_to_clipboard": "Failed to copy to clipboard",
"failed_to_load_organizations": "Failed to load organizations",
"failed_to_load_workspaces": "Failed to load workspaces",
"filter": "Filter",
"finish": "Finish",
"first_name": "First Name",
"follow_these": "Follow these",

View File

@@ -225,7 +225,6 @@
"failed_to_copy_to_clipboard": "Error al copiar al portapapeles",
"failed_to_load_organizations": "Error al cargar organizaciones",
"failed_to_load_workspaces": "Error al cargar los proyectos",
"filter": "Filtro",
"finish": "Finalizar",
"first_name": "Nombre",
"follow_these": "Sigue estos",

View File

@@ -112,7 +112,6 @@
"link_expired_description": "Le lien que vous avez utilisé n'est plus valide."
},
"common": {
"Filter": "Filtrer",
"accepted": "Accepté",
"account": "Compte",
"account_settings": "Paramètres du compte",
@@ -226,7 +225,6 @@
"failed_to_copy_to_clipboard": "Échec de la copie dans le presse-papiers",
"failed_to_load_organizations": "Échec du chargement des organisations",
"failed_to_load_workspaces": "Échec du chargement des projets",
"filter": "Filtre",
"finish": "Terminer",
"first_name": "Prénom",
"follow_these": "Suivez ceci",

View File

@@ -225,7 +225,6 @@
"failed_to_copy_to_clipboard": "Nem sikerült másolni a vágólapra",
"failed_to_load_organizations": "Nem sikerült betölteni a szervezeteket",
"failed_to_load_workspaces": "Nem sikerült a munkaterületek betöltése",
"filter": "Szűrő",
"finish": "Befejezés",
"first_name": "Keresztnév",
"follow_these": "Ezek követése",

View File

@@ -225,7 +225,6 @@
"failed_to_copy_to_clipboard": "クリップボードへのコピーに失敗しました",
"failed_to_load_organizations": "組織の読み込みに失敗しました",
"failed_to_load_workspaces": "ワークスペースの読み込みに失敗しました",
"filter": "フィルター",
"finish": "完了",
"first_name": "名",
"follow_these": "こちらの手順に従って",

View File

@@ -225,7 +225,6 @@
"failed_to_copy_to_clipboard": "Kopiëren naar klembord mislukt",
"failed_to_load_organizations": "Laden van organisaties mislukt",
"failed_to_load_workspaces": "Laden van werkruimtes mislukt",
"filter": "Filter",
"finish": "Finish",
"first_name": "Voornaam",
"follow_these": "Volg deze",

View File

@@ -225,7 +225,6 @@
"failed_to_copy_to_clipboard": "Falha ao copiar para a área de transferência",
"failed_to_load_organizations": "Falha ao carregar organizações",
"failed_to_load_workspaces": "Falha ao carregar projetos",
"filter": "Filtro",
"finish": "Terminar",
"first_name": "Primeiro nome",
"follow_these": "Siga esses",

View File

@@ -225,7 +225,6 @@
"failed_to_copy_to_clipboard": "Falha ao copiar para a área de transferência",
"failed_to_load_organizations": "Falha ao carregar organizações",
"failed_to_load_workspaces": "Falha ao carregar projetos",
"filter": "Filtro",
"finish": "Concluir",
"first_name": "Primeiro nome",
"follow_these": "Siga estes",

View File

@@ -225,7 +225,6 @@
"failed_to_copy_to_clipboard": "Nu s-a reușit copierea în clipboard",
"failed_to_load_organizations": "Nu s-a reușit încărcarea organizațiilor",
"failed_to_load_workspaces": "Nu s-au putut încărca workspaces",
"filter": "Filtru",
"finish": "Finalizează",
"first_name": "Prenume",
"follow_these": "Urmați acestea",

View File

@@ -112,7 +112,6 @@
"link_expired_description": "Ссылка, которой вы воспользовались, больше не действительна."
},
"common": {
"Filter": "Фильтр",
"accepted": "Принято",
"account": "Аккаунт",
"account_settings": "Настройки аккаунта",
@@ -226,7 +225,6 @@
"failed_to_copy_to_clipboard": "Не удалось скопировать в буфер обмена",
"failed_to_load_organizations": "Не удалось загрузить организации",
"failed_to_load_workspaces": "Не удалось загрузить рабочие пространства",
"filter": "Фильтр",
"finish": "Завершить",
"first_name": "Имя",
"follow_these": "Выполните следующие действия",

View File

@@ -225,7 +225,6 @@
"failed_to_copy_to_clipboard": "Misslyckades att kopiera till urklipp",
"failed_to_load_organizations": "Misslyckades att ladda organisationer",
"failed_to_load_workspaces": "Det gick inte att ladda arbetsytor",
"filter": "Filter",
"finish": "Slutför",
"first_name": "Förnamn",
"follow_these": "Följ dessa",

View File

@@ -225,7 +225,6 @@
"failed_to_copy_to_clipboard": "复制到剪贴板失败",
"failed_to_load_organizations": "加载组织失败",
"failed_to_load_workspaces": "加载工作区失败",
"filter": "筛选",
"finish": "完成",
"first_name": "名字",
"follow_these": "遵循 这些",

View File

@@ -225,7 +225,6 @@
"failed_to_copy_to_clipboard": "無法複製到剪貼簿",
"failed_to_load_organizations": "無法載入組織",
"failed_to_load_workspaces": "載入工作區失敗",
"filter": "篩選",
"finish": "完成",
"first_name": "名字",
"follow_these": "按照這些步驟",

View File

@@ -52,41 +52,54 @@ export const getPersonSegmentIds = async (
return [];
}
// Phase 1: Build WHERE clauses sequentially to avoid connection pool contention.
// segmentFilterToPrismaQuery can itself hit the DB (e.g. unmigrated-row checks),
// so running all builds concurrently would saturate the pool.
// Phase 1: Build all Prisma where clauses concurrently.
// This converts segment filters into where clauses without per-contact DB queries.
const segmentWithClauses = await Promise.all(
segments.map(async (segment) => {
const filters = segment.filters as TBaseFilters | null;
if (!filters || filters.length === 0) {
return { segmentId: segment.id, whereClause: {} as Prisma.ContactWhereInput };
}
const queryResult = await segmentFilterToPrismaQuery(segment.id, filters, environmentId, deviceType);
if (!queryResult.ok) {
logger.warn(
{ segmentId: segment.id, environmentId, error: queryResult.error },
"Failed to build Prisma query for segment"
);
return { segmentId: segment.id, whereClause: null };
}
return { segmentId: segment.id, whereClause: queryResult.data.whereClause };
})
);
// Separate segments into: always-match (no filters), needs-DB-check, and failed-to-build
const alwaysMatchIds: string[] = [];
const dbChecks: { segmentId: string; whereClause: Prisma.ContactWhereInput }[] = [];
const toCheck: { segmentId: string; whereClause: Prisma.ContactWhereInput }[] = [];
for (const segment of segments) {
const filters = segment.filters as TBaseFilters;
if (!filters?.length) {
alwaysMatchIds.push(segment.id);
for (const item of segmentWithClauses) {
if (item.whereClause === null) {
continue;
}
const queryResult = await segmentFilterToPrismaQuery(segment.id, filters, environmentId, deviceType);
if (!queryResult.ok) {
logger.warn(
{ segmentId: segment.id, environmentId, error: queryResult.error },
"Failed to build Prisma query for segment, skipping"
);
continue;
if (Object.keys(item.whereClause).length === 0) {
alwaysMatchIds.push(item.segmentId);
} else {
toCheck.push({ segmentId: item.segmentId, whereClause: item.whereClause });
}
dbChecks.push({ segmentId: segment.id, whereClause: queryResult.data.whereClause });
}
if (dbChecks.length === 0) {
if (toCheck.length === 0) {
return alwaysMatchIds;
}
// Phase 2: Execute all membership checks in a single transaction.
// Uses one connection instead of N concurrent ones, eliminating pool contention.
const txResults = await prisma.$transaction(
dbChecks.map(({ whereClause }) =>
// Phase 2: Batch all contact-match checks into a single DB transaction.
// Replaces N individual findFirst queries with one batched round-trip.
const batchResults = await prisma.$transaction(
toCheck.map(({ whereClause }) =>
prisma.contact.findFirst({
where: { id: contactId, ...whereClause },
select: { id: true },
@@ -94,12 +107,17 @@ export const getPersonSegmentIds = async (
)
);
const matchedIds = dbChecks.filter((_, i) => txResults[i] !== null).map(({ segmentId }) => segmentId);
// Phase 3: Collect matching segment IDs
const dbMatchIds = toCheck.filter((_, i) => batchResults[i] !== null).map(({ segmentId }) => segmentId);
return [...alwaysMatchIds, ...matchedIds];
return [...alwaysMatchIds, ...dbMatchIds];
} catch (error) {
logger.warn(
{ environmentId, contactId, error },
{
environmentId,
contactId,
error,
},
"Failed to get person segment IDs, returning empty array"
);
return [];

View File

@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TJsPersonState } from "@formbricks/types/js";
import { getPersonSegmentIds } from "./segments";
import { getUserState } from "./user-state";
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findUniqueOrThrow: vi.fn(),
},
},
}));
vi.mock("./segments", () => ({
getPersonSegmentIds: vi.fn(),
}));
const mockEnvironmentId = "test-environment-id";
const mockUserId = "test-user-id";
const mockContactId = "test-contact-id";
const mockDevice = "desktop";
describe("getUserState", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
test("should return user state with empty responses and displays", async () => {
const mockContactData = {
id: mockContactId,
responses: [],
displays: [],
};
vi.mocked(prisma.contact.findUniqueOrThrow).mockResolvedValue(mockContactData as any);
vi.mocked(getPersonSegmentIds).mockResolvedValue(["segment1"]);
const result = await getUserState({
environmentId: mockEnvironmentId,
userId: mockUserId,
contactId: mockContactId,
device: mockDevice,
});
expect(prisma.contact.findUniqueOrThrow).toHaveBeenCalledWith({
where: { id: mockContactId },
select: {
id: true,
responses: {
select: { surveyId: true },
},
displays: {
select: { surveyId: true, createdAt: true },
orderBy: { createdAt: "desc" },
},
},
});
expect(getPersonSegmentIds).toHaveBeenCalledWith(
mockEnvironmentId,
mockContactId,
mockUserId,
mockDevice
);
expect(result).toEqual<TJsPersonState["data"]>({
contactId: mockContactId,
userId: mockUserId,
segments: ["segment1"],
displays: [],
responses: [],
lastDisplayAt: null,
});
});
test("should return user state with responses and displays, and sort displays by createdAt", async () => {
const mockDate1 = new Date("2023-01-01T00:00:00.000Z");
const mockDate2 = new Date("2023-01-02T00:00:00.000Z");
const mockContactData = {
id: mockContactId,
responses: [{ surveyId: "survey1" }, { surveyId: "survey2" }],
displays: [
{ surveyId: "survey4", createdAt: mockDate2 }, // most recent (already sorted by desc)
{ surveyId: "survey3", createdAt: mockDate1 },
],
};
vi.mocked(prisma.contact.findUniqueOrThrow).mockResolvedValue(mockContactData as any);
vi.mocked(getPersonSegmentIds).mockResolvedValue(["segment2", "segment3"]);
const result = await getUserState({
environmentId: mockEnvironmentId,
userId: mockUserId,
contactId: mockContactId,
device: mockDevice,
});
expect(result).toEqual<TJsPersonState["data"]>({
contactId: mockContactId,
userId: mockUserId,
segments: ["segment2", "segment3"],
displays: [
{ surveyId: "survey4", createdAt: mockDate2 },
{ surveyId: "survey3", createdAt: mockDate1 },
],
responses: ["survey1", "survey2"],
lastDisplayAt: mockDate2,
});
});
test("should handle empty arrays from prisma", async () => {
// This case tests with proper empty arrays instead of null
const mockContactData = {
id: mockContactId,
responses: [],
displays: [],
};
vi.mocked(prisma.contact.findUniqueOrThrow).mockResolvedValue(mockContactData as any);
vi.mocked(getPersonSegmentIds).mockResolvedValue([]);
const result = await getUserState({
environmentId: mockEnvironmentId,
userId: mockUserId,
contactId: mockContactId,
device: mockDevice,
});
expect(result).toEqual<TJsPersonState["data"]>({
contactId: mockContactId,
userId: mockUserId,
segments: [],
displays: [],
responses: [],
lastDisplayAt: null,
});
});
});

View File

@@ -0,0 +1,80 @@
import { prisma } from "@formbricks/database";
import { TJsPersonState } from "@formbricks/types/js";
import { getPersonSegmentIds } from "./segments";
/**
* Optimized single query to get all user state data
* Replaces multiple separate queries with one efficient query
*/
const getUserStateDataOptimized = async (contactId: string) => {
return prisma.contact.findUniqueOrThrow({
where: { id: contactId },
select: {
id: true,
responses: {
select: { surveyId: true },
},
displays: {
select: {
surveyId: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
},
},
});
};
/**
* Optimized user state fetcher without caching
* Uses single database query and efficient data processing
* NO CACHING - user state changes frequently with contact updates
*
* @param environmentId - The environment id
* @param userId - The user id
* @param device - The device type
* @returns The person state
* @throws {ValidationError} - If the input is invalid
* @throws {ResourceNotFoundError} - If the environment or organization is not found
*/
export const getUserState = async ({
environmentId,
userId,
contactId,
device,
}: {
environmentId: string;
userId: string;
contactId: string;
device: "phone" | "desktop";
}): Promise<TJsPersonState["data"]> => {
// Single optimized query for all contact data
const contactData = await getUserStateDataOptimized(contactId);
// Get segments using Prisma-based evaluation (no attributes needed - fetched from DB)
const segments = await getPersonSegmentIds(environmentId, contactId, userId, device);
// Process displays efficiently
const displays = (contactData.displays ?? []).map((display) => ({
surveyId: display.surveyId,
createdAt: display.createdAt,
}));
// Get latest display date
const lastDisplayAt =
contactData.displays && contactData.displays.length > 0 ? contactData.displays[0].createdAt : null;
// Process responses efficiently
const responses = (contactData.responses ?? []).map((response) => response.surveyId);
const userState: TJsPersonState["data"] = {
contactId,
userId,
segments,
displays,
responses,
lastDisplayAt,
};
return userState;
};

View File

@@ -274,7 +274,9 @@ export const ThemeStyling = ({
survey={previewSurvey(project.name, t)}
project={{
...project,
styling: { ...form.watch(), brandColor: { light: previewBrandColor } },
styling: form.watch("allowStyleOverwrite")
? { ...form.watch(), brandColor: { light: previewBrandColor } }
: STYLE_DEFAULTS,
}}
previewType={previewSurveyType}
setPreviewType={setPreviewSurveyType}

View File

@@ -54,9 +54,9 @@
"@opentelemetry/sdk-node": "0.211.0",
"@opentelemetry/sdk-trace-base": "2.5.0",
"@opentelemetry/semantic-conventions": "1.38.0",
"@prisma/instrumentation": "6.14.0",
"@paralleldrive/cuid2": "2.2.2",
"@prisma/client": "6.14.0",
"@prisma/instrumentation": "6.14.0",
"@radix-ui/react-accordion": "1.2.10",
"@radix-ui/react-checkbox": "1.3.1",
"@radix-ui/react-collapsible": "1.1.10",
@@ -114,12 +114,10 @@
"prismjs": "1.30.0",
"qr-code-styling": "1.9.2",
"qrcode": "1.5.4",
"react": "19.2.3",
"react-calendar": "5.1.0",
"react-colorful": "5.6.1",
"react-confetti": "6.4.0",
"react-day-picker": "9.6.7",
"react-dom": "19.2.3",
"react-hook-form": "7.56.2",
"react-hot-toast": "2.5.2",
"react-i18next": "15.7.3",
@@ -137,8 +135,10 @@
"uuid": "11.1.0",
"webpack": "5.99.8",
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
"zod": "3.25.76",
"zod-openapi": "4.2.4"
"zod": "3.24.4",
"zod-openapi": "4.2.4",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",

View File

@@ -99,7 +99,7 @@
"xm-and-surveys/surveys/link-surveys/personal-links",
"xm-and-surveys/surveys/link-surveys/single-use-links",
"xm-and-surveys/surveys/link-surveys/source-tracking",
"xm-and-surveys/surveys/link-surveys/start-at-block",
"xm-and-surveys/surveys/link-surveys/start-at-question",
"xm-and-surveys/surveys/link-surveys/verify-email-before-survey",
"xm-and-surveys/surveys/link-surveys/market-research-panel",
"xm-and-surveys/surveys/link-surveys/pin-protected-surveys",
@@ -516,13 +516,9 @@
"source": "/docs/link-surveys/global/schedule-start-end-dates"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/start-at-block",
"destination": "/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
"source": "/docs/link-surveys/start-at-question"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/start-at-block",
"source": "/docs/xm-and-surveys/surveys/link-surveys/start-at-question"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/metadata",
"source": "/docs/link-surveys/global/metadata"

View File

@@ -1,42 +0,0 @@
---
title: "Start At Specific Block"
description:
"Start a survey at a specific block using the URL to skip earlier blocks."
icon: "arrow-right"
---
The `startAt` URL parameter lets you open a Link Survey at a specific **block** instead of from the beginning. This is useful when you want to link to a specific part of the survey from external sources or reuse the same survey at different points in the user journey.
## How it works
The survey navigation is block-based: each block can contain one or more questions. When you pass `startAt` with a Question ID, the survey finds the **block** that contains that question and opens at that block. All questions in that block are shown together.
<Note>
**Multi-question blocks:** When a block has multiple questions, `startAt` opens at the block—you will see all questions in that block, not only the question whose ID you used. For precise "start at this exact question" behavior, use one question per block.
</Note>
## How to use it
1. In the Survey Editor, open the Questions Tab and ensure the survey is set as a **Link Survey**.
2. Find the question (or block) you want to start at, click on **Show Advanced Settings**, and copy the **Question ID** of any question in that block.
<Note>
Each question has a unique Question ID. Since `startAt` resolves to the block containing the question, you can use any question ID from the target block—typically the first question in that block.
</Note>
3. Append `?startAt=question_id` to your survey's URL, replacing `question_id` with the copied Question ID.
4. Share this modified URL with your users to start the survey at the specified block.
### Sample Link Survey URL with `startAt`
```sh Example Link Survey URL with startAt configured
https://formbricks.com/clny997dj087ho30fdzyf4nkl?startAt=bqd29m94l9k0hnc3azbrexl8
```
## Use cases
- **Link to a specific block from an external source:** Direct users to a specific block in your survey from emails, chatbots, or web pages.
- **Use the same survey in different parts of the user journey:** Reuse the survey at different stages, starting at different blocks to gather insights.
- **Create a personalized survey experience:** Tailor the survey by starting at a particular block based on the user's past interactions or preferences.

View File

@@ -0,0 +1,36 @@
---
title: "Start At Specific Question"
description:
"Start a survey at a specific question using the URL to skip the initial questions."
icon: "arrow-right"
---
You can start a survey at a specific question from the survey using the URL to skip the initial questions. This is useful when you want to link to a specific question from an external source or want to use the same survey in different parts of the user journey.
## How to Use it?
1. In the Survey Editor, open the Questions Tab and ensure the survey is set as a **Link Survey**.
2. Find the question you want to start at, click on **Show Advanced Settings**, and copy the **Question ID**.
<Note>
Each question has a unique Question ID, which is used to identify it in the
survey. You can use different Question IDs for multiple **startAt** points in
the URL.
</Note>
3. Append `?startAt=question_id` to your survey's URL, replacing `question_id` with the copied Question ID.
4. Share this modified URL with your users to start the survey at the specified question.
### Sample Link Survey URL with `startAt`
```sh Example Link Survey URL with startAt configured
https://formbricks.com/clny997dj087ho30fdzyf4nkl?startAt=bqd29m94l9k0hnc3azbrexl8
```
## Use Cases
- **Link to a specific question from an external source:** Use this feature to direct users to a specific question in your survey from emails, chatbots, or web pages, providing a seamless experience.
- **Use the same survey in different parts of the user journey:** Employ the same survey at various stages of the user journey, starting at different questions to gather comprehensive insights.
- **Create a personalized survey experience:** Tailor the survey experience by starting at a particular question based on the user's past interactions or preferences, enhancing engagement.

View File

@@ -39,7 +39,7 @@
"dependencies": {
"@formbricks/logger": "workspace:*",
"redis": "5.8.1",
"zod": "3.25.76"
"zod": "3.24.4"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",

View File

@@ -52,7 +52,7 @@
"@prisma/client": "6.14.0",
"bcryptjs": "2.4.3",
"uuid": "11.1.0",
"zod": "3.25.76",
"zod": "3.24.4",
"zod-openapi": "4.2.4"
},
"devDependencies": {

View File

@@ -35,7 +35,7 @@
},
"author": "Formbricks <hola@formbricks.com>",
"dependencies": {
"zod": "3.25.76",
"zod": "3.24.4",
"pino": "10.0.0",
"pino-opentelemetry-transport": "2.0.0",
"pino-pretty": "13.1.1"

View File

@@ -80,7 +80,7 @@ describe("service.ts", () => {
Key: "uploads/images/test-file.jpg",
Fields: {
"Content-Type": "image/jpeg",
success_action_status: "201",
// "Content-Encoding": "base64",
},
Conditions: [["content-length-range", 0, mockMaxSize]],
});
@@ -175,7 +175,7 @@ describe("service.ts", () => {
Key: "uploads/images/test-file.jpg",
Fields: {
"Content-Type": "image/jpeg",
success_action_status: "201",
// "Content-Encoding": "base64",
},
Conditions: [["content-length-range", 0, mockMaxSize]],
});

View File

@@ -66,7 +66,7 @@ export const getSignedUploadUrl = async (
Key: `${filePath}/${fileName}`,
Fields: {
"Content-Type": contentType,
success_action_status: "201",
// "Content-Encoding": "base64",
},
Conditions: postConditions,
});

View File

@@ -141,18 +141,18 @@ function DropdownVariant({
};
return (
<div>
<div className="space-y-2">
<ElementError errorMessage={errorMessage} dir={dir} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
disabled={disabled}
className="rounded-input min-h-input bg-input-bg border-input-border text-input-text py-input-y px-input-x w-full justify-between"
className="rounded-input bg-option-bg rounded-option border-option-border h-input my-0 w-full justify-between border"
aria-invalid={Boolean(errorMessage)}
aria-label={headline}>
<span className="font-input font-input-weight text-input-text truncate">{displayText}</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
<ChevronDown className="label-headline ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -235,7 +235,7 @@ function DropdownVariant({
disabled={disabled}
aria-required={required}
dir={dir}
className="mt-2 w-full"
className="w-full"
/>
) : null}
</div>

View File

@@ -151,7 +151,7 @@ function SingleSelect({
/>
{/* Options */}
<div>
<div className="space-y-2">
{variant === "dropdown" ? (
<>
<ElementError errorMessage={errorMessage} dir={dir} />
@@ -160,11 +160,11 @@ function SingleSelect({
<Button
variant="outline"
disabled={disabled}
className="rounded-input min-h-input bg-input-bg border-input-border text-input-text py-input-y px-input-x w-full justify-between"
className="rounded-input bg-option-bg rounded-option border-option-border h-input my-0 w-full justify-between border"
aria-invalid={Boolean(errorMessage)}
aria-label={headline}>
<span className="font-input font-input-weight text-input-text truncate">{displayText}</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
<ChevronDown className="label-headline ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -226,7 +226,7 @@ function SingleSelect({
placeholder={otherOptionPlaceholder}
disabled={disabled}
dir={dir}
className="mt-2 w-full"
className="w-full"
/>
) : null}
</>

View File

@@ -10,7 +10,7 @@
},
"dependencies": {
"@prisma/client": "6.14.0",
"zod": "3.25.76",
"zod": "3.24.4",
"zod-openapi": "4.2.4",
"node-html-parser": "7.0.1"
},

82
pnpm-lock.yaml generated
View File

@@ -28,7 +28,7 @@ importers:
dependencies:
next:
specifier: 16.1.6
version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
version: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react:
specifier: 19.2.3
version: 19.2.3
@@ -294,10 +294,10 @@ importers:
version: 1.2.6(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@sentry/nextjs':
specifier: 10.5.0
version: 10.5.0(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.99.8(esbuild@0.25.12))
version: 10.5.0(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.99.8(esbuild@0.25.12))
'@t3-oss/env-nextjs':
specifier: 0.13.4
version: 0.13.4(arktype@2.1.29)(typescript@5.8.3)(zod@3.25.76)
version: 0.13.4(arktype@2.1.29)(typescript@5.8.3)(zod@3.24.4)
'@tailwindcss/forms':
specifier: 0.5.10
version: 0.5.10(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.8.3)))
@@ -384,13 +384,13 @@ importers:
version: 3.0.1
next:
specifier: 16.1.6
version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
version: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next-auth:
specifier: 4.24.12
version: 4.24.12(patch_hash=7ac5717a8d7d2049442182b5d83ab492d33fe774ff51ff5ea3884628b77df87b)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
version: 4.24.12(patch_hash=7ac5717a8d7d2049442182b5d83ab492d33fe774ff51ff5ea3884628b77df87b)(next@16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next-safe-action:
specifier: 7.10.8
version: 7.10.8(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@3.25.76)
version: 7.10.8(next@16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@3.24.4)
node-fetch:
specifier: 3.3.2
version: 3.3.2
@@ -482,11 +482,11 @@ importers:
specifier: file:vendor/xlsx-0.20.3.tgz
version: file:apps/web/vendor/xlsx-0.20.3.tgz
zod:
specifier: 3.25.76
version: 3.25.76
specifier: 3.24.4
version: 3.24.4
zod-openapi:
specifier: 4.2.4
version: 4.2.4(zod@3.25.76)
version: 4.2.4(zod@3.24.4)
devDependencies:
'@formbricks/config-typescript':
specifier: workspace:*
@@ -582,8 +582,8 @@ importers:
specifier: 5.8.1
version: 5.8.1
zod:
specifier: 3.25.76
version: 3.25.76
specifier: 3.24.4
version: 3.24.4
devDependencies:
'@formbricks/config-typescript':
specifier: workspace:*
@@ -685,11 +685,11 @@ importers:
specifier: 11.1.0
version: 11.1.0
zod:
specifier: 3.25.76
version: 3.25.76
specifier: 3.24.4
version: 3.24.4
zod-openapi:
specifier: 4.2.4
version: 4.2.4(zod@3.25.76)
version: 4.2.4(zod@3.24.4)
devDependencies:
'@formbricks/config-typescript':
specifier: workspace:*
@@ -829,8 +829,8 @@ importers:
specifier: 13.1.1
version: 13.1.1
zod:
specifier: 3.25.76
version: 3.25.76
specifier: 3.24.4
version: 3.24.4
devDependencies:
'@formbricks/config-typescript':
specifier: workspace:*
@@ -1067,11 +1067,11 @@ importers:
specifier: 7.0.1
version: 7.0.1
zod:
specifier: 3.25.76
version: 3.25.76
specifier: 3.24.4
version: 3.24.4
zod-openapi:
specifier: 4.2.4
version: 4.2.4(zod@3.25.76)
version: 4.2.4(zod@3.24.4)
devDependencies:
'@formbricks/config-typescript':
specifier: workspace:*
@@ -11758,8 +11758,8 @@ packages:
peerDependencies:
zod: ^3.21.4
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@3.24.4:
resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==}
snapshots:
@@ -17049,7 +17049,7 @@ snapshots:
'@sentry/core@10.5.0': {}
'@sentry/nextjs@10.5.0(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.99.8(esbuild@0.25.12))':
'@sentry/nextjs@10.5.0(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.99.8(esbuild@0.25.12))':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.38.0
@@ -17062,7 +17062,7 @@ snapshots:
'@sentry/vercel-edge': 10.5.0
'@sentry/webpack-plugin': 4.6.1(encoding@0.1.13)(webpack@5.99.8(esbuild@0.25.12))
chalk: 3.0.0
next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
resolve: 1.22.8
rollup: 4.54.0
stacktrace-parser: 0.1.11
@@ -17928,19 +17928,19 @@ snapshots:
dependencies:
tslib: 2.8.1
'@t3-oss/env-core@0.13.4(arktype@2.1.29)(typescript@5.8.3)(zod@3.25.76)':
'@t3-oss/env-core@0.13.4(arktype@2.1.29)(typescript@5.8.3)(zod@3.24.4)':
dependencies:
arktype: 2.1.29
optionalDependencies:
typescript: 5.8.3
zod: 3.25.76
zod: 3.24.4
'@t3-oss/env-nextjs@0.13.4(arktype@2.1.29)(typescript@5.8.3)(zod@3.25.76)':
'@t3-oss/env-nextjs@0.13.4(arktype@2.1.29)(typescript@5.8.3)(zod@3.24.4)':
dependencies:
'@t3-oss/env-core': 0.13.4(arktype@2.1.29)(typescript@5.8.3)(zod@3.25.76)
'@t3-oss/env-core': 0.13.4(arktype@2.1.29)(typescript@5.8.3)(zod@3.24.4)
optionalDependencies:
typescript: 5.8.3
zod: 3.25.76
zod: 3.24.4
transitivePeerDependencies:
- arktype
@@ -22186,13 +22186,13 @@ snapshots:
neo-async@2.6.2: {}
next-auth@4.24.12(patch_hash=7ac5717a8d7d2049442182b5d83ab492d33fe774ff51ff5ea3884628b77df87b)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
next-auth@4.24.12(patch_hash=7ac5717a8d7d2049442182b5d83ab492d33fe774ff51ff5ea3884628b77df87b)(next@16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
'@babel/runtime': 7.28.4
'@panva/hkdf': 1.2.1
cookie: 0.7.2
jose: 4.15.9
next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
oauth: 0.9.15
openid-client: 5.7.1
preact: 10.28.2
@@ -22203,13 +22203,13 @@ snapshots:
optionalDependencies:
nodemailer: 7.0.11
next-safe-action@7.10.8(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@3.25.76):
next-safe-action@7.10.8(next@16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@3.24.4):
dependencies:
next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
zod: 3.25.76
zod: 3.24.4
next@16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
@@ -22219,7 +22219,7 @@ snapshots:
postcss: 8.4.31
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
styled-jsx: 5.1.6(react@19.2.3)
styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.3)
optionalDependencies:
'@next/swc-darwin-arm64': 16.0.10
'@next/swc-darwin-x64': 16.0.10
@@ -22236,7 +22236,7 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
next@16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
'@next/env': 16.1.6
'@swc/helpers': 0.5.15
@@ -22245,7 +22245,7 @@ snapshots:
postcss: 8.4.31
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
styled-jsx: 5.1.6(react@19.2.3)
styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.3)
optionalDependencies:
'@next/swc-darwin-arm64': 16.1.6
'@next/swc-darwin-x64': 16.1.6
@@ -23991,10 +23991,12 @@ snapshots:
stubborn-utils@1.0.2: {}
styled-jsx@5.1.6(react@19.2.3):
styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.3):
dependencies:
client-only: 0.0.1
react: 19.2.3
optionalDependencies:
'@babel/core': 7.28.5
stylis@4.3.6: {}
@@ -25087,8 +25089,8 @@ snapshots:
yoga-layout@3.2.1: {}
zod-openapi@4.2.4(zod@3.25.76):
zod-openapi@4.2.4(zod@3.24.4):
dependencies:
zod: 3.25.76
zod: 3.24.4
zod@3.25.76: {}
zod@3.24.4: {}