Compare commits

...

2 Commits

Author SHA1 Message Date
Johannes
b70b2eef95 fix: vimeo + loom embed (#7018)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-12-20 08:08:48 +00:00
Harsh Bhat
392a95834b docs: Best practices Panel Management (#7011) 2025-12-20 06:32:57 +00:00
13 changed files with 260 additions and 31 deletions

View File

@@ -61,6 +61,9 @@ describe("convertToEmbedUrl", () => {
expect(convertToEmbedUrl("https://www.vimeo.com/123456789")).toBe(
"https://player.vimeo.com/video/123456789"
);
expect(convertToEmbedUrl("https://player.vimeo.com/video/123456789")).toBe(
"https://player.vimeo.com/video/123456789"
);
});
test("converts Loom URL to embed URL", () => {
@@ -70,6 +73,9 @@ describe("convertToEmbedUrl", () => {
expect(convertToEmbedUrl("https://loom.com/share/abcdef123456")).toBe(
"https://www.loom.com/embed/abcdef123456"
);
expect(convertToEmbedUrl("https://www.loom.com/embed/abcdef123456")).toBe(
"https://www.loom.com/embed/abcdef123456"
);
});
test("returns undefined for unsupported URLs", () => {
@@ -109,6 +115,7 @@ describe("extractVimeoId", () => {
test("extracts video ID from Vimeo URLs", () => {
expect(extractVimeoId("https://vimeo.com/123456789")).toBe("123456789");
expect(extractVimeoId("https://www.vimeo.com/123456789")).toBe("123456789");
expect(extractVimeoId("https://player.vimeo.com/video/123456789")).toBe("123456789");
});
test("returns null for invalid Vimeo URLs", () => {
@@ -121,6 +128,7 @@ describe("extractLoomId", () => {
test("extracts video ID from Loom URLs", () => {
expect(extractLoomId("https://loom.com/share/abcdef123456")).toBe("abcdef123456");
expect(extractLoomId("https://www.loom.com/share/abcdef123456")).toBe("abcdef123456");
expect(extractLoomId("https://www.loom.com/embed/abcdef123456")).toBe("abcdef123456");
});
test("returns null for invalid Loom URLs", async () => {

View File

@@ -26,7 +26,7 @@ export const checkForVimeoUrl = (url: string): boolean => {
if (vimeoUrl.protocol !== "https:") return false;
const vimeoDomains = ["www.vimeo.com", "vimeo.com"];
const vimeoDomains = ["www.vimeo.com", "vimeo.com", "player.vimeo.com"];
const hostname = vimeoUrl.hostname;
return vimeoDomains.includes(hostname);
@@ -74,7 +74,7 @@ export const extractYoutubeId = (url: string): string | null => {
};
export const extractVimeoId = (url: string): string | null => {
const regExp = /vimeo\.com\/(\d+)/;
const regExp = /vimeo\.com\/(?:video\/)?(\d+)/;
const match = regExp.exec(url);
if (match?.[1]) {
@@ -85,7 +85,7 @@ export const extractVimeoId = (url: string): string | null => {
};
export const extractLoomId = (url: string): string | null => {
const regExp = /loom\.com\/share\/([a-zA-Z0-9]+)/;
const regExp = /loom\.com\/(?:share|embed)\/([a-zA-Z0-9]+)/;
const match = regExp.exec(url);
if (match?.[1]) {

View File

@@ -138,5 +138,15 @@ describe("File Input Utils", () => {
test("returns false for non-YouTube URLs", () => {
expect(checkForYoutubePrivacyMode("https://www.example.com")).toBe(false);
});
test("should return false for empty or whitespace-only string", () => {
expect(checkForYoutubePrivacyMode("")).toBe(false);
expect(checkForYoutubePrivacyMode(" ")).toBe(false);
});
test("should return false for non-string types", () => {
expect(checkForYoutubePrivacyMode(null as any)).toBe(false);
expect(checkForYoutubePrivacyMode(123 as any)).toBe(false);
});
});
});

View File

@@ -86,6 +86,10 @@ export const getAllowedFiles = async (
};
export const checkForYoutubePrivacyMode = (url: string): boolean => {
if (!url || typeof url !== "string" || url.trim() === "") {
return false;
}
try {
const parsedUrl = new URL(url);
return parsedUrl.host === "www.youtube-nocookie.com";

View File

@@ -202,7 +202,8 @@
"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/improve-trial-cr",
"xm-and-surveys/xm/best-practices/research-panel"
]
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -0,0 +1,198 @@
---
title: "Panel Management"
description: "Build and manage your own research panel using Formbricks to collect profiling data, create targeted segments, and distribute personalized survey links to panel members."
icon: "users-rectangle"
---
## Purpose
A research panel is a pre-recruited group of participants who have agreed to take part in multiple surveys over time. Building your own panel enables you to:
- Conduct research with a well-understood audience
- Target specific demographics or user profiles
- Distribute surveys quickly without recruiting new participants each time
- Track participant engagement and responses over time
## Formbricks Approach
Formbricks provides all the tools you need to build and manage a research panel:
- **Profiling surveys** to collect participant demographics and preferences
- **Contact management** to store and organize panel member data
- **Segments** to group participants by attributes for targeted research
- **Personal links** to distribute unique survey links to specific panel members
## Overview
Building a research panel with Formbricks involves these key steps:
1. Create a profiling survey to collect panel member information
2. Export responses and prepare contact data
3. Upload contacts into Formbricks
4. Create segments to group panel members
5. Create research surveys and distribute personal links
## Step-by-step guide
<Steps>
<Step title="Create a profiling survey">
Start by creating a survey to collect essential information about your panel members. This profiling survey should gather:
- **Contact information**: Email, name, and any identifiers you need
- **Demographics**: Age, location, occupation, etc.
- **Preferences**: Product usage, interests, or other relevant attributes
To create your profiling survey:
1. Go to [app.formbricks.com](https://app.formbricks.com) and click **Create Survey**
2. Choose a blank survey or start from a template
3. Add questions to collect the data you need for segmentation
4. Include a **Contact Info** question type to capture email addresses
![Contact Info question type](/images/xm-and-surveys/core-features/question-type/contact-info.webp)
</Step>
<Step title="Collect profiling responses">
Distribute your profiling survey and collect responses from potential panel members. You can share the survey via:
- Direct link
- Email
- Social media
- Your website
Wait until you have collected a sufficient number of responses to build your panel.
</Step>
<Step title="Download the responses CSV">
Once you have collected responses, export them for processing:
1. Navigate to your profiling survey's **Summary** page
2. Click the **Download** button
3. Select **CSV** format
4. Save the file to your computer
![Download responses CSV](/images/xm-and-surveys/xm/best-practices/panel-management/download-responses.webp)
This CSV contains all the profiling data from your respondents.
</Step>
<Step title="Download the example contact upload CSV">
To understand the required format for uploading contacts, download the example CSV:
1. Go to the **Contacts** section in your project
2. Click **Upload Contacts**
3. Download the **Example CSV** to see the required column structure
![Download example contacts CSV](/images/xm-and-surveys/xm/best-practices/panel-management/download-example-contacts.webp)
The example CSV shows you the format Formbricks expects for contact uploads, including required fields like email and optional attribute columns.
</Step>
<Step title="Map profiling responses to contact attributes">
Transform your profiling survey responses into the contact upload format:
1. Open both CSV files (responses and example template)
2. Create a new spreadsheet matching the upload template structure
3. Map each profiling survey question to a contact attribute column:
- Email → `email` column
- Name → `firstName`, `lastName` columns
- Other answers → Custom attribute columns
Example mapping:
| Survey Question | Contact Attribute |
|----------------|-------------------|
| "What is your email?" | `email` |
| "What is your name?" | `firstName`, `lastName` |
| "What industry do you work in?" | `industry` |
| "How large is your company?" | `companySize` |
| "What is your job title?" | `jobTitle` |
</Step>
<Step title="Upload contacts to Formbricks">
Import your panel members into Formbricks:
1. Go to the **Contacts** section
2. Click **Upload Contacts**
3. Select your prepared CSV file
4. Review the attribute mapping
5. Complete the upload
Your panel members are now stored in Formbricks with their profiling attributes.
</Step>
<Step title="Create segments for targeting">
Group your panel members into segments based on their attributes:
1. Go to the **Contacts** tab
2. Click **Create Segment**
![Create a new segment](/images/xm-and-surveys/surveys/website-app-surveys/targeting/contacts.webp)
3. Define filter conditions based on the attributes you collected:
- Example: `industry` equals "Technology" AND `companySize` equals "50-200"
![Attribute filter](/images/xm-and-surveys/surveys/website-app-surveys/targeting/attribute-filter.webp)
4. Name your segment descriptively (e.g., "Tech SMB Professionals")
5. Save the segment
Create multiple segments for different research needs. Learn more about [Advanced Targeting](/xm-and-surveys/surveys/website-app-surveys/advanced-targeting) for detailed segmentation options.
</Step>
<Step title="Create a research survey">
Now create the survey you want to distribute to your panel:
1. Click **Create Survey**
2. Design your research survey with the questions you need
3. Configure survey settings as needed
4. Publish the survey
</Step>
<Step title="Generate personal links for your segment">
Distribute unique survey links to your panel segment:
1. Navigate to your research survey's **Summary** page
2. Click **Share survey**
3. Select the **Personal Links** tab
4. Choose the segment you want to survey from the dropdown
5. Optionally set an expiry date for the links
6. Click **Generate & download links**
![Personal Links](/images/xm-and-surveys/xm/best-practices/panel-management/personal-links.webp)
You'll receive a CSV file containing unique survey links for each panel member in the segment. Learn more about [Personal Links](/xm-and-surveys/surveys/link-surveys/personal-links).
</Step>
<Step title="Distribute your survey">
Send the personal links to your panel members using your preferred method:
- Email marketing platform
- Direct email
- SMS
- Any other communication channel
Each panel member receives their unique link, and their responses will be automatically attributed to their contact record.
</Step>
</Steps>
## Benefits of this approach
| Benefit | Description |
|---------|-------------|
| **Response attribution** | Know exactly who responded to each survey |
| **Targeted research** | Survey specific segments without bothering others |
| **Panel management** | Maintain a centralized database of research participants |
| **Reusability** | Use the same panel for multiple research projects |
| **Data enrichment** | Build up participant profiles over time with each survey |
## Next steps
- [Personal Links](/xm-and-surveys/surveys/link-surveys/personal-links) - Learn more about generating and managing personal survey links
- [Advanced Targeting](/xm-and-surveys/surveys/website-app-surveys/advanced-targeting) - Explore detailed segmentation options
- [Hidden Fields](/xm-and-surveys/surveys/general-features/hidden-fields) - Pass additional data into surveys via URL parameters

View File

@@ -3,30 +3,18 @@ import * as React from "react";
import { cn } from "@/lib/utils";
import { checkForLoomUrl, checkForVimeoUrl, checkForYoutubeUrl, convertToEmbedUrl } from "@/lib/video";
// Function to add extra params to videoUrls in order to reduce video controls
const getVideoUrlWithParams = (videoUrl: string): string | undefined => {
// First convert to embed URL
const embedUrl = convertToEmbedUrl(videoUrl);
if (!embedUrl) return undefined;
//Function to add extra params to videoUrls in order to reduce video controls
const getVideoUrlWithParams = (videoUrl: string): string => {
const isYoutubeVideo = checkForYoutubeUrl(videoUrl);
const isVimeoUrl = checkForVimeoUrl(videoUrl);
const isLoomUrl = checkForLoomUrl(videoUrl);
if (isYoutubeVideo) {
// For YouTube, add parameters to embed URL
const separator = embedUrl.includes("?") ? "&" : "?";
return `${embedUrl}${separator}controls=0`;
} else if (isVimeoUrl) {
// For Vimeo, add parameters to embed URL
const separator = embedUrl.includes("?") ? "&" : "?";
return `${embedUrl}${separator}title=false&transcript=false&speed=false&quality_selector=false&progress_bar=false&pip=false&fullscreen=false&cc=false&chromecast=false`;
} else if (isLoomUrl) {
// For Loom, add parameters to embed URL
const separator = embedUrl.includes("?") ? "&" : "?";
return `${embedUrl}${separator}hide_share=true&hideEmbedTopBar=true&hide_title=true`;
}
return embedUrl;
if (isYoutubeVideo) return videoUrl.concat("?controls=0");
else if (isVimeoUrl)
return videoUrl.concat(
"?title=false&transcript=false&speed=false&quality_selector=false&progress_bar=false&pip=false&fullscreen=false&cc=false&chromecast=false"
);
else if (isLoomUrl) return videoUrl.concat("?hide_share=true&hideEmbedTopBar=true&hide_title=true");
return videoUrl;
};
interface ElementMediaProps {

View File

@@ -117,6 +117,16 @@ describe("convertToEmbedUrl", () => {
expect(result).toBe("https://player.vimeo.com/video/987654321");
});
test("handles already-embedded Vimeo URLs", () => {
const result = convertToEmbedUrl("https://player.vimeo.com/video/123456789");
expect(result).toBe("https://player.vimeo.com/video/123456789");
});
test("handles Vimeo URLs with query parameters", () => {
const result = convertToEmbedUrl("https://vimeo.com/123456789?some=param");
expect(result).toBe("https://player.vimeo.com/video/123456789");
});
test("returns undefined for invalid Vimeo URLs", () => {
const result = convertToEmbedUrl("https://www.vimeo.com/invalid");
expect(result).toBeUndefined();
@@ -134,6 +144,16 @@ describe("convertToEmbedUrl", () => {
expect(result).toBe("https://www.loom.com/embed/xyz789");
});
test("handles already-embedded Loom URLs", () => {
const result = convertToEmbedUrl("https://www.loom.com/embed/abc123def456");
expect(result).toBe("https://www.loom.com/embed/abc123def456");
});
test("handles Loom URLs with query parameters", () => {
const result = convertToEmbedUrl("https://www.loom.com/share/abc123def456?some=param");
expect(result).toBe("https://www.loom.com/embed/abc123def456");
});
test("returns undefined for invalid Loom URLs", () => {
const result = convertToEmbedUrl("https://www.loom.com/invalid");
expect(result).toBeUndefined();

View File

@@ -27,7 +27,7 @@ export const checkForVimeoUrl = (url: string): boolean => {
if (vimeoUrl.protocol !== "https:") return false;
const vimeoDomains = ["www.vimeo.com", "vimeo.com"];
const vimeoDomains = ["www.vimeo.com", "vimeo.com", "player.vimeo.com"];
const hostname = vimeoUrl.hostname;
return vimeoDomains.includes(hostname);
@@ -77,14 +77,14 @@ const extractYoutubeId = (url: string): string | null => {
};
const extractVimeoId = (url: string): string | null => {
const regExp = /vimeo\.com\/(?<videoId>\d+)/;
const regExp = /vimeo\.com\/(?:video\/)?(?<videoId>\d+)/;
const match = regExp.exec(url);
return match?.groups?.videoId ?? null;
};
const extractLoomId = (url: string): string | null => {
const regExp = /loom\.com\/share\/(?<videoId>[a-zA-Z0-9]+)/;
const regExp = /loom\.com\/(?:share|embed)\/(?<videoId>[a-zA-Z0-9]+)/;
const match = regExp.exec(url);
return match?.groups?.videoId ?? null;

View File

@@ -27,7 +27,7 @@ export const checkForVimeoUrl = (url: string): boolean => {
if (vimeoUrl.protocol !== "https:") return false;
const vimeoDomains = ["www.vimeo.com", "vimeo.com"];
const vimeoDomains = ["www.vimeo.com", "vimeo.com", "player.vimeo.com"];
const hostname = vimeoUrl.hostname;
return vimeoDomains.includes(hostname);
@@ -77,7 +77,7 @@ export const extractYoutubeId = (url: string): string | null => {
};
const extractVimeoId = (url: string): string | null => {
const regExp = /vimeo\.com\/(\d+)/;
const regExp = /vimeo\.com\/(?:video\/)?(\d+)/;
const match = url.match(regExp);
if (match && match[1]) {
@@ -87,7 +87,7 @@ const extractVimeoId = (url: string): string | null => {
};
const extractLoomId = (url: string): string | null => {
const regExp = /loom\.com\/share\/([a-zA-Z0-9]+)/;
const regExp = /loom\.com\/(?:share|embed)\/([a-zA-Z0-9]+)/;
const match = url.match(regExp);
if (match && match[1]) {