mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 18:30:32 -06:00
chore: merge with main
This commit is contained in:
@@ -965,7 +965,7 @@ checksums:
|
||||
environments/settings/domain/pretty_url: 10a4b387b6df844245fc842be29527e3
|
||||
environments/settings/domain/project: e13002ec4570f3fcc2f050f5ce974294
|
||||
environments/settings/domain/survey_name: 169f24df0e2f42254c5458f32eb7bf00
|
||||
environments/settings/domain/title: 402d46965eacc3af4c5df92e53e95712
|
||||
environments/settings/domain/title: 2c3a2951a31218e8e73f07cda69ed5ff
|
||||
environments/settings/enterprise/audit_logs: c7efb33d09676938d07651774b067bf6
|
||||
environments/settings/enterprise/coming_soon: ee2b0671e00972773210c5be5a9ccb89
|
||||
environments/settings/enterprise/contacts_and_segments: 5795d9e89c0c10e1ddbbc6fc65b8d90f
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -1038,7 +1038,7 @@
|
||||
"pretty_url": "Pretty URL",
|
||||
"project": "Projekt",
|
||||
"survey_name": "Umfragename",
|
||||
"title": "Domain"
|
||||
"title": "Pretty URLs"
|
||||
},
|
||||
"enterprise": {
|
||||
"audit_logs": "Audit Logs",
|
||||
|
||||
@@ -1038,7 +1038,7 @@
|
||||
"pretty_url": "URL bonita",
|
||||
"project": "Proyecto",
|
||||
"survey_name": "Nombre de la encuesta",
|
||||
"title": "Dominio"
|
||||
"title": "URL bonitas"
|
||||
},
|
||||
"enterprise": {
|
||||
"audit_logs": "Registros de auditoría",
|
||||
|
||||
@@ -1038,7 +1038,7 @@
|
||||
"pretty_url": "URL personnalisée",
|
||||
"project": "Projet",
|
||||
"survey_name": "Nom de l'enquête",
|
||||
"title": "Domaine"
|
||||
"title": "URL personnalisées"
|
||||
},
|
||||
"enterprise": {
|
||||
"audit_logs": "Journaux d'audit",
|
||||
|
||||
@@ -1038,7 +1038,7 @@
|
||||
"pretty_url": "カスタムURL",
|
||||
"project": "プロジェクト",
|
||||
"survey_name": "フォーム名",
|
||||
"title": "ドメイン"
|
||||
"title": "カスタムURL"
|
||||
},
|
||||
"enterprise": {
|
||||
"audit_logs": "監査ログ",
|
||||
|
||||
@@ -1038,7 +1038,7 @@
|
||||
"pretty_url": "Pretty URL",
|
||||
"project": "Project",
|
||||
"survey_name": "Enquêtenaam",
|
||||
"title": "Domein"
|
||||
"title": "Pretty URL's"
|
||||
},
|
||||
"enterprise": {
|
||||
"audit_logs": "Auditlogboeken",
|
||||
|
||||
@@ -1038,7 +1038,7 @@
|
||||
"pretty_url": "URL amigável",
|
||||
"project": "Projeto",
|
||||
"survey_name": "Nome da Pesquisa",
|
||||
"title": "Domínio"
|
||||
"title": "URLs amigáveis"
|
||||
},
|
||||
"enterprise": {
|
||||
"audit_logs": "Registros de Auditoria",
|
||||
|
||||
@@ -1038,7 +1038,7 @@
|
||||
"pretty_url": "URL amigável",
|
||||
"project": "Projeto",
|
||||
"survey_name": "Nome do inquérito",
|
||||
"title": "Domínio"
|
||||
"title": "URLs amigáveis"
|
||||
},
|
||||
"enterprise": {
|
||||
"audit_logs": "Registos de Auditoria",
|
||||
|
||||
@@ -1038,7 +1038,7 @@
|
||||
"pretty_url": "Pretty URL",
|
||||
"project": "Proiect",
|
||||
"survey_name": "Nume chestionar",
|
||||
"title": "Domeniu"
|
||||
"title": "URL-uri frumoase"
|
||||
},
|
||||
"enterprise": {
|
||||
"audit_logs": "Jurnale de audit",
|
||||
|
||||
@@ -1038,7 +1038,7 @@
|
||||
"pretty_url": "Pretty URL",
|
||||
"project": "Projekt",
|
||||
"survey_name": "Enkätnamn",
|
||||
"title": "Domän"
|
||||
"title": "Pretty URL:er"
|
||||
},
|
||||
"enterprise": {
|
||||
"audit_logs": "Granskningsloggar",
|
||||
|
||||
@@ -1038,7 +1038,7 @@
|
||||
"pretty_url": "美化 URL",
|
||||
"project": "项目",
|
||||
"survey_name": "调查名称",
|
||||
"title": "域名"
|
||||
"title": "美化 URL"
|
||||
},
|
||||
"enterprise": {
|
||||
"audit_logs": "审计日志",
|
||||
|
||||
@@ -1038,7 +1038,7 @@
|
||||
"pretty_url": "Pretty URL",
|
||||
"project": "專案",
|
||||
"survey_name": "問卷名稱",
|
||||
"title": "網域"
|
||||
"title": "美化網址"
|
||||
},
|
||||
"enterprise": {
|
||||
"audit_logs": "稽核記錄",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Column, Hr, Row, Text } from "@react-email/components";
|
||||
import dompurify from "isomorphic-dompurify";
|
||||
import React from "react";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -35,11 +35,16 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JS
|
||||
<>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: dompurify.sanitize(body, {
|
||||
ALLOWED_TAGS: ["p", "span", "b", "strong", "i", "em", "a", "br"],
|
||||
ALLOWED_ATTR: ["href", "rel", "dir", "class"],
|
||||
ALLOWED_URI_REGEXP: /^https?:\/\//, // Only allow safe URLs starting with http or https
|
||||
ADD_ATTR: ["target"], // Optional: Allow 'target' attribute for links (e.g., _blank)
|
||||
__html: sanitizeHtml(body, {
|
||||
allowedTags: ["p", "span", "b", "strong", "i", "em", "a", "br"],
|
||||
allowedAttributes: {
|
||||
a: ["href", "rel", "target"],
|
||||
"*": ["dir", "class"],
|
||||
},
|
||||
allowedSchemes: ["http", "https"],
|
||||
allowedSchemesByTag: {
|
||||
a: ["http", "https"],
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -72,8 +72,8 @@
|
||||
"@radix-ui/react-tooltip": "1.2.6",
|
||||
"@react-email/components": "0.0.38",
|
||||
"@sentry/nextjs": "10.5.0",
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@t3-oss/env-nextjs": "0.13.4",
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@ungap/structured-clone": "1.3.0",
|
||||
@@ -111,16 +111,17 @@
|
||||
"prismjs": "1.30.0",
|
||||
"qr-code-styling": "1.9.2",
|
||||
"qrcode": "1.5.4",
|
||||
"react-calendar": "5.1.0",
|
||||
"react-colorful": "5.6.1",
|
||||
"react-confetti": "6.4.0",
|
||||
"react-day-picker": "9.6.7",
|
||||
"react-hook-form": "7.56.2",
|
||||
"react-hot-toast": "2.5.2",
|
||||
"react-calendar": "5.1.0",
|
||||
"react-i18next": "15.7.3",
|
||||
"react-turnstile": "1.1.4",
|
||||
"react-use": "17.6.0",
|
||||
"redis": "4.7.0",
|
||||
"sanitize-html": "2.17.0",
|
||||
"server-only": "0.0.1",
|
||||
"sharp": "0.34.1",
|
||||
"stripe": "16.12.0",
|
||||
@@ -148,6 +149,7 @@
|
||||
"@types/nodemailer": "7.0.2",
|
||||
"@types/papaparse": "5.3.15",
|
||||
"@types/qrcode": "1.5.5",
|
||||
"@types/sanitize-html": "2.16.0",
|
||||
"@types/testing-library__react": "10.2.0",
|
||||
"@types/ungap__structured-clone": "1.2.0",
|
||||
"@vitest/coverage-v8": "3.1.3",
|
||||
|
||||
@@ -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 |
@@ -40,12 +40,9 @@ icon: "code"
|
||||
</Tip>
|
||||
|
||||
<Note>
|
||||
**Important**: Any changes to actions, surveys, or environment configuration will take about 1 minute to
|
||||
reflect in your app/website running the formbricks sdk with debug mode enabled due to server-side caching.
|
||||
This includes new actions, modified action configurations, and survey trigger updates. For quick updates
|
||||
during development and testing, you can enable [Debug
|
||||
Mode](/xm-and-surveys/surveys/website-app-surveys/framework-guides#activate-debug-mode) in your SDK
|
||||
configuration.
|
||||
**Important**: Any changes to actions, surveys, or environment configuration will take **up to 1 minute** to
|
||||
reflect in your app/website due to server-side caching. This includes new actions, modified action
|
||||
configurations, and survey trigger updates.
|
||||
</Note>
|
||||
|
||||
## **Setting Up No-Code Actions**
|
||||
|
||||
@@ -59,18 +59,36 @@ sidebarTitle: "Quickstart"
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Publish the survey">
|
||||
Publish the survey to make it available for the SDK to pull into the website or app where you want to show it.
|
||||
</Step>
|
||||
<Step title="Publish the survey">
|
||||
Publish the survey to make it available for the SDK to pull into the website or app where you want to show
|
||||
it.
|
||||
</Step>
|
||||
|
||||
<Step title="Understand caching behavior">
|
||||
For better scalability, we cache the request the SDK makes to the server. This allows you to use Formbricks
|
||||
on websites with millions of visitors without high hosting cost.
|
||||
<Note>
|
||||
**Important**: Any changes to surveys, action classes, project settings, or environment configuration will
|
||||
take **up to 1 minute** to reflect in your app/website due to server-side caching. This includes survey
|
||||
modifications, new triggers, styling changes, and other updates.
|
||||
</Note>
|
||||
For troubleshooting, you can enable [Debug
|
||||
Mode](/xm-and-surveys/surveys/website-app-surveys/framework-guides#activate-debug-mode) to see detailed logs
|
||||
in your browser console.
|
||||
</Step>
|
||||
|
||||
<Step title="Check browser compatibility">
|
||||
Formbricks is designed to work with all modern browsers. Please ensure your users are using a supported version:
|
||||
|
||||
| Browser | Minimum Version | Release Date |
|
||||
| :--- | :--- | :--- |
|
||||
| **Chrome** | 111+ | March 2023 |
|
||||
| **Edge** | 111+ | March 2023 |
|
||||
| **Firefox** | 128+ | July 2024 |
|
||||
| **Safari** | 16.4+ | March 2023 |
|
||||
|
||||
*Internet Explorer is not supported.*
|
||||
|
||||
<Step title="Enable debug mode in website / app">
|
||||
For better scalability, we cache the request the SDK makes to the server. This allows you to use Formbricks on websites with millions of visitors without high hosting cost. On the downside, there can be **up to a 5 minute delay** until the SDK pulls the newest surveys from the server.
|
||||
|
||||
<Note>
|
||||
**Important**: Any changes to surveys, action classes, project settings, or environment configuration will take up to 5 minutes to reflect in debug mode in your app/website due to server-side caching. This includes survey modifications, new triggers, styling changes, and other updates.
|
||||
</Note>
|
||||
|
||||
To avoid the delay during development and testing, please switch on the [Debug Mode.](/xm-and-surveys/surveys/website-app-surveys/framework-guides#activate-debug-mode)
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
|
||||
198
docs/xm-and-surveys/xm/best-practices/research-panel.mdx
Normal file
198
docs/xm-and-surveys/xm/best-practices/research-panel.mdx
Normal 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
|
||||
|
||||

|
||||
|
||||
|
||||
</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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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**
|
||||
|
||||

|
||||
|
||||
3. Define filter conditions based on the attributes you collected:
|
||||
- Example: `industry` equals "Technology" AND `companySize` equals "50-200"
|
||||
|
||||

|
||||
|
||||
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**
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
@@ -30,6 +30,10 @@ export interface ConsentProps {
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the checkbox is disabled */
|
||||
disabled?: boolean;
|
||||
/** Image URL to display above the headline */
|
||||
imageUrl?: string;
|
||||
/** Video URL to display above the headline */
|
||||
videoUrl?: string;
|
||||
}
|
||||
|
||||
function Consent({
|
||||
@@ -44,6 +48,8 @@ function Consent({
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
}: Readonly<ConsentProps>): React.JSX.Element {
|
||||
const handleCheckboxChange = (checked: boolean): void => {
|
||||
if (disabled) return;
|
||||
@@ -53,7 +59,14 @@ function Consent({
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
/>
|
||||
|
||||
{/* Consent Checkbox */}
|
||||
<div className="relative space-y-2">
|
||||
|
||||
@@ -34,6 +34,10 @@ export interface CTAProps {
|
||||
disabled?: boolean;
|
||||
/** Variant for the button */
|
||||
buttonVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "custom";
|
||||
/** Image URL to display above the headline */
|
||||
imageUrl?: string;
|
||||
/** Video URL to display above the headline */
|
||||
videoUrl?: string;
|
||||
}
|
||||
|
||||
function CTA({
|
||||
@@ -50,6 +54,8 @@ function CTA({
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
buttonVariant = "default",
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
}: Readonly<CTAProps>): React.JSX.Element {
|
||||
const handleButtonClick = (): void => {
|
||||
if (disabled) return;
|
||||
@@ -63,7 +69,14 @@ function CTA({
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
/>
|
||||
|
||||
{/* CTA Button */}
|
||||
<div className="relative space-y-2">
|
||||
|
||||
@@ -31,6 +31,10 @@ interface DateElementProps {
|
||||
disabled?: boolean;
|
||||
/** Locale code for date formatting (e.g., "en-US", "de-DE", "fr-FR"). Defaults to browser locale or "en-US" */
|
||||
locale?: string;
|
||||
/** Image URL to display above the headline */
|
||||
imageUrl?: string;
|
||||
/** Video URL to display above the headline */
|
||||
videoUrl?: string;
|
||||
}
|
||||
|
||||
function DateElement({
|
||||
@@ -47,6 +51,8 @@ function DateElement({
|
||||
disabled = false,
|
||||
locale = "en-US",
|
||||
errorMessage,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
}: Readonly<DateElementProps>): React.JSX.Element {
|
||||
// Initialize date from value string, parsing as local time to avoid timezone issues
|
||||
const [date, setDate] = React.useState<Date | undefined>(() => {
|
||||
@@ -89,46 +95,67 @@ function DateElement({
|
||||
}
|
||||
};
|
||||
|
||||
// Convert minDate/maxDate strings to Date objects
|
||||
const minDateObj = minDate ? new Date(minDate) : undefined;
|
||||
const maxDateObj = maxDate ? new Date(maxDate) : undefined;
|
||||
|
||||
// Create disabled function for date restrictions
|
||||
const isDateDisabled = React.useCallback(
|
||||
(dateToCheck: Date): boolean => {
|
||||
if (disabled) return true;
|
||||
if (minDateObj) {
|
||||
const minAtMidnight = new Date(minDateObj.getFullYear(), minDateObj.getMonth(), minDateObj.getDate());
|
||||
const checkAtMidnight = new Date(
|
||||
dateToCheck.getFullYear(),
|
||||
dateToCheck.getMonth(),
|
||||
dateToCheck.getDate()
|
||||
);
|
||||
if (checkAtMidnight < minAtMidnight) return true;
|
||||
}
|
||||
if (maxDateObj) {
|
||||
const maxAtMidnight = new Date(maxDateObj.getFullYear(), maxDateObj.getMonth(), maxDateObj.getDate());
|
||||
const checkAtMidnight = new Date(
|
||||
dateToCheck.getFullYear(),
|
||||
dateToCheck.getMonth(),
|
||||
dateToCheck.getDate()
|
||||
);
|
||||
if (checkAtMidnight > maxAtMidnight) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[disabled, minDateObj, maxDateObj]
|
||||
);
|
||||
|
||||
// Get locale for date formatting
|
||||
const dateLocale = React.useMemo(() => {
|
||||
return locale ? getDateFnsLocale(locale) : undefined;
|
||||
}, [locale]);
|
||||
|
||||
const startMonth = React.useMemo(() => {
|
||||
if (!minDate) return undefined;
|
||||
try {
|
||||
const [year, month, day] = minDate.split("-").map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, [minDate]);
|
||||
|
||||
const endMonth = React.useMemo(() => {
|
||||
if (!maxDate) return undefined;
|
||||
try {
|
||||
const [year, month, day] = maxDate.split("-").map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, [maxDate]);
|
||||
|
||||
// Create disabled function for date restrictions
|
||||
const isDateDisabled = React.useCallback(
|
||||
(dateToCheck: Date): boolean => {
|
||||
if (disabled) return true;
|
||||
|
||||
const checkAtMidnight = new Date(
|
||||
dateToCheck.getFullYear(),
|
||||
dateToCheck.getMonth(),
|
||||
dateToCheck.getDate()
|
||||
);
|
||||
|
||||
if (startMonth) {
|
||||
const minAtMidnight = new Date(startMonth.getFullYear(), startMonth.getMonth(), startMonth.getDate());
|
||||
if (checkAtMidnight < minAtMidnight) return true;
|
||||
}
|
||||
|
||||
if (endMonth) {
|
||||
const maxAtMidnight = new Date(endMonth.getFullYear(), endMonth.getMonth(), endMonth.getDate());
|
||||
if (checkAtMidnight > maxAtMidnight) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[disabled, endMonth, startMonth]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
@@ -137,7 +164,10 @@ function DateElement({
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
defaultMonth={date}
|
||||
captionLayout="dropdown"
|
||||
startMonth={startMonth}
|
||||
endMonth={endMonth}
|
||||
disabled={isDateDisabled}
|
||||
onSelect={handleDateSelect}
|
||||
locale={dateLocale}
|
||||
|
||||
@@ -236,7 +236,6 @@ export const MultipleElements: Story = {
|
||||
description="You can upload multiple images"
|
||||
allowMultiple
|
||||
allowedFileExtensions={[".jpg", ".png", ".gif"]}
|
||||
maxSizeInMB={5}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -43,6 +43,10 @@ interface FormFieldProps {
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the controls are disabled */
|
||||
disabled?: boolean;
|
||||
/** Image URL to display above the headline */
|
||||
imageUrl?: string;
|
||||
/** Video URL to display above the headline */
|
||||
videoUrl?: string;
|
||||
}
|
||||
|
||||
function FormField({
|
||||
@@ -56,6 +60,8 @@ function FormField({
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
}: Readonly<FormFieldProps>): React.JSX.Element {
|
||||
// Ensure value is always an object
|
||||
const currentValues = React.useMemo(() => {
|
||||
@@ -93,7 +99,13 @@ function FormField({
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} />
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
/>
|
||||
|
||||
{/* Form Fields */}
|
||||
<div className="relative space-y-3">
|
||||
|
||||
@@ -41,6 +41,10 @@ interface MatrixProps {
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the options are disabled */
|
||||
disabled?: boolean;
|
||||
/** Image URL to display above the headline */
|
||||
imageUrl?: string;
|
||||
/** Video URL to display above the headline */
|
||||
videoUrl?: string;
|
||||
}
|
||||
|
||||
function Matrix({
|
||||
@@ -56,6 +60,8 @@ function Matrix({
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
}: Readonly<MatrixProps>): React.JSX.Element {
|
||||
// Ensure value is always an object (value already has default of {})
|
||||
const selectedValues = value;
|
||||
@@ -78,7 +84,14 @@ function Matrix({
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
/>
|
||||
|
||||
{/* Matrix Table */}
|
||||
<div className="relative">
|
||||
|
||||
@@ -67,6 +67,10 @@ interface MultiSelectProps {
|
||||
onOtherValueChange?: (value: string) => void;
|
||||
/** IDs of options that should be exclusive (selecting them deselects all others) */
|
||||
exclusiveOptionIds?: string[];
|
||||
/** Image URL to display above the headline */
|
||||
imageUrl?: string;
|
||||
/** Video URL to display above the headline */
|
||||
videoUrl?: string;
|
||||
}
|
||||
|
||||
// Shared className for option labels
|
||||
@@ -137,7 +141,7 @@ function DropdownVariant({
|
||||
|
||||
const isRequired = getIsRequired();
|
||||
|
||||
const handleOptionToggle = (optionId: string) => {
|
||||
const handleOptionToggle = (optionId: string): void => {
|
||||
if (selectedValues.includes(optionId)) {
|
||||
handleOptionRemove(optionId);
|
||||
} else {
|
||||
@@ -440,6 +444,8 @@ function MultiSelect({
|
||||
otherValue = "",
|
||||
onOtherValueChange,
|
||||
exclusiveOptionIds = [],
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
}: Readonly<MultiSelectProps>): React.JSX.Element {
|
||||
// Ensure value is always an array
|
||||
const selectedValues = Array.isArray(value) ? value : [];
|
||||
@@ -491,7 +497,14 @@ function MultiSelect({
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
/>
|
||||
|
||||
{/* Options */}
|
||||
<div className="relative">
|
||||
|
||||
@@ -31,6 +31,10 @@ interface NPSProps {
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the controls are disabled */
|
||||
disabled?: boolean;
|
||||
/** Image URL to display above the headline */
|
||||
imageUrl?: string;
|
||||
/** Video URL to display above the headline */
|
||||
videoUrl?: string;
|
||||
}
|
||||
|
||||
function NPS({
|
||||
@@ -47,6 +51,8 @@ function NPS({
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
}: Readonly<NPSProps>): React.JSX.Element {
|
||||
const [hoveredValue, setHoveredValue] = React.useState<number | null>(null);
|
||||
|
||||
@@ -162,7 +168,14 @@ function NPS({
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
/>
|
||||
|
||||
{/* NPS Options */}
|
||||
<div className="relative space-y-2">
|
||||
|
||||
@@ -24,6 +24,8 @@ interface OpenTextProps {
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
rows?: number;
|
||||
disabled?: boolean;
|
||||
imageUrl?: string;
|
||||
videoUrl?: string;
|
||||
}
|
||||
|
||||
function OpenText({
|
||||
@@ -42,6 +44,8 @@ function OpenText({
|
||||
dir = "auto",
|
||||
rows = 3,
|
||||
disabled = false,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
}: Readonly<OpenTextProps>): React.JSX.Element {
|
||||
const [currentLength, setCurrentLength] = useState(value.length);
|
||||
|
||||
@@ -64,7 +68,14 @@ function OpenText({
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
/>
|
||||
|
||||
{/* Input or Textarea */}
|
||||
<div className="relative space-y-2">
|
||||
|
||||
@@ -43,6 +43,10 @@ interface PictureSelectProps {
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the options are disabled */
|
||||
disabled?: boolean;
|
||||
/** Image URL to display above the headline */
|
||||
imageUrl?: string;
|
||||
/** Video URL to display above the headline */
|
||||
videoUrl?: string;
|
||||
}
|
||||
|
||||
function PictureSelect({
|
||||
@@ -58,6 +62,8 @@ function PictureSelect({
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
}: Readonly<PictureSelectProps>): React.JSX.Element {
|
||||
// Ensure value is always the correct type
|
||||
let selectedValues: string[] | string | undefined;
|
||||
@@ -86,7 +92,14 @@ function PictureSelect({
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
/>
|
||||
|
||||
{/* Picture Grid - 2 columns */}
|
||||
<div className="relative">
|
||||
|
||||
@@ -43,6 +43,10 @@ interface RankingProps {
|
||||
dir?: TextDirection;
|
||||
/** Whether the controls are disabled */
|
||||
disabled?: boolean;
|
||||
/** Image URL to display above the headline */
|
||||
imageUrl?: string;
|
||||
/** Video URL to display above the headline */
|
||||
videoUrl?: string;
|
||||
}
|
||||
|
||||
interface RankingItemProps {
|
||||
@@ -74,7 +78,14 @@ function getBottomButtonRadiusClass(isLast: boolean, dir?: TextDirection): strin
|
||||
return "rounded-br-md";
|
||||
}
|
||||
|
||||
function RankingItem({ item, rankedIds, onItemClick, onMove, disabled, dir }: Readonly<RankingItemProps>) {
|
||||
function RankingItem({
|
||||
item,
|
||||
rankedIds,
|
||||
onItemClick,
|
||||
onMove,
|
||||
disabled,
|
||||
dir,
|
||||
}: Readonly<RankingItemProps>): React.ReactNode {
|
||||
const isRanked = rankedIds.includes(item.id);
|
||||
const rankIndex = rankedIds.indexOf(item.id);
|
||||
const isFirst = isRanked && rankIndex === 0;
|
||||
@@ -183,6 +194,8 @@ function Ranking({
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
}: Readonly<RankingProps>): React.JSX.Element {
|
||||
// Ensure value is always an array
|
||||
const rankedIds = React.useMemo(() => (Array.isArray(value) ? value : []), [value]);
|
||||
@@ -232,7 +245,14 @@ function Ranking({
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
/>
|
||||
|
||||
{/* Ranking Options */}
|
||||
<div className="relative">
|
||||
|
||||
@@ -143,6 +143,10 @@ interface RatingProps {
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the controls are disabled */
|
||||
disabled?: boolean;
|
||||
/** Image URL to display above the headline */
|
||||
imageUrl?: string;
|
||||
/** Video URL to display above the headline */
|
||||
videoUrl?: string;
|
||||
}
|
||||
|
||||
function Rating({
|
||||
@@ -161,6 +165,8 @@ function Rating({
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
}: Readonly<RatingProps>): React.JSX.Element {
|
||||
const [hoveredValue, setHoveredValue] = React.useState<number | null>(null);
|
||||
|
||||
@@ -399,7 +405,14 @@ function Rating({
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
/>
|
||||
|
||||
{/* Rating Options */}
|
||||
<div className="relative space-y-2">
|
||||
|
||||
@@ -61,6 +61,10 @@ interface SingleSelectProps {
|
||||
otherValue?: string;
|
||||
/** Callback when the 'other' input value changes */
|
||||
onOtherValueChange?: (value: string) => void;
|
||||
/** Image URL to display above the headline */
|
||||
imageUrl?: string;
|
||||
/** Video URL to display above the headline */
|
||||
videoUrl?: string;
|
||||
}
|
||||
|
||||
function SingleSelect({
|
||||
@@ -82,6 +86,8 @@ function SingleSelect({
|
||||
otherOptionPlaceholder = "Please specify",
|
||||
otherValue = "",
|
||||
onOtherValueChange,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
}: Readonly<SingleSelectProps>): React.JSX.Element {
|
||||
// Ensure value is always a string or undefined
|
||||
const selectedValue = value ?? undefined;
|
||||
@@ -131,7 +137,14 @@ function SingleSelect({
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
/>
|
||||
|
||||
{/* Options */}
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -60,6 +60,8 @@ function Calendar({
|
||||
formatters,
|
||||
components,
|
||||
locale,
|
||||
startMonth,
|
||||
endMonth,
|
||||
...props
|
||||
}: Readonly<
|
||||
React.ComponentProps<typeof DayPicker> & {
|
||||
@@ -78,6 +80,22 @@ function Calendar({
|
||||
return locale;
|
||||
}, [locale]);
|
||||
|
||||
const resolvedStartMonth = React.useMemo(() => {
|
||||
if (startMonth) return startMonth;
|
||||
if (captionLayout === "dropdown") {
|
||||
return new Date(new Date().getFullYear() - 100, 0);
|
||||
}
|
||||
return undefined;
|
||||
}, [startMonth, captionLayout]);
|
||||
|
||||
const resolvedEndMonth = React.useMemo(() => {
|
||||
if (endMonth) return endMonth;
|
||||
if (captionLayout === "dropdown") {
|
||||
return new Date(new Date().getFullYear() + 100, 11);
|
||||
}
|
||||
return undefined;
|
||||
}, [endMonth, captionLayout]);
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
@@ -89,6 +107,8 @@ function Calendar({
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
locale={resolvedLocale}
|
||||
startMonth={resolvedStartMonth}
|
||||
endMonth={resolvedEndMonth}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) => {
|
||||
if (resolvedLocale) {
|
||||
@@ -156,7 +176,7 @@ function Calendar({
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none bg-brand opacity-50",
|
||||
"bg-accent text-brand-foreground rounded-md data-[selected=true]:rounded-none bg-brand opacity-50",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
|
||||
@@ -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 {
|
||||
@@ -35,16 +23,12 @@ interface ElementMediaProps {
|
||||
altText?: string;
|
||||
}
|
||||
|
||||
function ElementMedia({
|
||||
imgUrl,
|
||||
videoUrl,
|
||||
altText = "Image",
|
||||
}: Readonly<ElementMediaProps>): React.JSX.Element {
|
||||
function ElementMedia({ imgUrl, videoUrl, altText = "Image" }: Readonly<ElementMediaProps>): React.ReactNode {
|
||||
const videoUrlWithParams = videoUrl ? getVideoUrlWithParams(videoUrl) : undefined;
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
|
||||
if (!imgUrl && !videoUrl) {
|
||||
return <></>;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -119,6 +119,8 @@ export function AddressElement({
|
||||
onChange={handleChange}
|
||||
required={element.required}
|
||||
dir={dir}
|
||||
imageUrl={element.imageUrl}
|
||||
videoUrl={element.videoUrl}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -53,6 +53,8 @@ export function ConsentElement({
|
||||
onChange={handleChange}
|
||||
required={element.required}
|
||||
dir={dir}
|
||||
imageUrl={element.imageUrl}
|
||||
videoUrl={element.videoUrl}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -115,6 +115,8 @@ export function ContactInfoElement({
|
||||
onChange={handleChange}
|
||||
required={element.required}
|
||||
dir={dir}
|
||||
imageUrl={element.imageUrl}
|
||||
videoUrl={element.videoUrl}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -65,6 +65,8 @@ export function CTAElement({
|
||||
onClick={handleClick}
|
||||
// CTA cannot be required
|
||||
required={false}
|
||||
imageUrl={element.imageUrl}
|
||||
videoUrl={element.videoUrl}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -56,13 +56,12 @@ export function DateElement({
|
||||
setTtc(updatedTtcObj);
|
||||
};
|
||||
|
||||
// Use default date range (100 years ago to year 3000)
|
||||
const getMinDate = (): string | undefined => {
|
||||
return new Date(new Date().getFullYear() - 100, 0, 1).toISOString().split("T")[0];
|
||||
};
|
||||
|
||||
const getMaxDate = (): string | undefined => {
|
||||
return "3000-12-31";
|
||||
return new Date(new Date().getFullYear() + 100, 0, 1).toISOString().split("T")[0];
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -79,6 +78,8 @@ export function DateElement({
|
||||
required={element.required}
|
||||
errorMessage={errorMessage}
|
||||
locale={languageCode}
|
||||
imageUrl={element.imageUrl}
|
||||
videoUrl={element.videoUrl}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -134,6 +134,8 @@ export function MatrixElement({
|
||||
value={convertValueToIds(value)}
|
||||
onChange={handleChange}
|
||||
required={element.required}
|
||||
imageUrl={element.imageUrl}
|
||||
videoUrl={element.videoUrl}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -258,6 +258,8 @@ export function MultipleChoiceMultiElement({
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={handleOtherValueChange}
|
||||
exclusiveOptionIds={noneOption ? [noneOption.id] : []}
|
||||
imageUrl={element.imageUrl}
|
||||
videoUrl={element.videoUrl}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -175,6 +175,8 @@ export function MultipleChoiceSingleElement({
|
||||
}
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={handleOtherValueChange}
|
||||
imageUrl={element.imageUrl}
|
||||
videoUrl={element.videoUrl}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -58,6 +58,8 @@ export function NPSElement({
|
||||
colorCoding={element.isColorCodingEnabled}
|
||||
required={element.required}
|
||||
dir={dir}
|
||||
imageUrl={element.imageUrl}
|
||||
videoUrl={element.videoUrl}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -129,6 +129,8 @@ export function OpenTextElement({
|
||||
errorMessage={errorMessage}
|
||||
dir={dir}
|
||||
rows={3}
|
||||
imageUrl={element.imageUrl}
|
||||
videoUrl={element.videoUrl}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -98,6 +98,8 @@ export function PictureSelectionElement({
|
||||
required={element.required}
|
||||
dir={dir}
|
||||
errorMessage={errorMessage}
|
||||
imageUrl={element.imageUrl}
|
||||
videoUrl={element.videoUrl}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -137,6 +137,8 @@ export function RankingElement({
|
||||
onChange={handleChange}
|
||||
required={element.required}
|
||||
errorMessage={errorMessage}
|
||||
imageUrl={element.imageUrl}
|
||||
videoUrl={element.videoUrl}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -59,6 +59,8 @@ export function RatingElement({
|
||||
colorCoding={element.isColorCodingEnabled}
|
||||
required={element.required}
|
||||
dir={dir}
|
||||
imageUrl={element.imageUrl}
|
||||
videoUrl={element.videoUrl}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -295,7 +295,7 @@ export function BlockConditional({
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-row-reverse justify-between",
|
||||
fullSizeCards ? "sticky bottom-0 bg-white" : ""
|
||||
fullSizeCards ? "sticky bottom-0 bg-survey-bg" : ""
|
||||
)}>
|
||||
<div>
|
||||
<SubmitButton
|
||||
|
||||
@@ -21,9 +21,12 @@ export function ErrorComponent({ errorType }: ErrorComponentProps) {
|
||||
const error = errorData[errorType];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center bg-white p-8 text-center" role="alert" aria-live="assertive">
|
||||
<span className="mb-1.5 text-base font-bold leading-6 text-slate-900">{error.title}</span>
|
||||
<p className="max-w-lg text-sm font-normal leading-6 text-slate-600">{error.message}</p>
|
||||
<div
|
||||
className="bg-survey-bg text-heading flex flex-col items-center p-8 text-center"
|
||||
role="alert"
|
||||
aria-live="assertive">
|
||||
<span className="mb-1.5 text-base font-bold leading-6">{error.title}</span>
|
||||
<p className="max-w-lg text-sm font-normal leading-6">{error.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,11 +19,9 @@ export function ResponseErrorComponent({
|
||||
}: ResponseErrorComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col bg-white p-4">
|
||||
<span className="mb-1.5 text-base font-bold leading-6 text-slate-900">
|
||||
{t("common.your_feedback_is_stuck")}
|
||||
</span>
|
||||
<p className="max-w-md text-sm font-normal leading-6 text-slate-600">
|
||||
<div className="bg-survey-bg text-heading flex flex-col p-4">
|
||||
<span className="mb-1.5 text-base font-bold leading-6">{t("common.your_feedback_is_stuck")}</span>
|
||||
<p className="max-w-md text-sm font-normal leading-6">
|
||||
{t("common.the_servers_cannot_be_reached_at_the_moment")}
|
||||
<br />
|
||||
{t("common.please_retry_now_or_try_again_later")}
|
||||
|
||||
@@ -743,7 +743,7 @@ export function Survey({
|
||||
return (
|
||||
<>
|
||||
{localSurvey.type !== "link" ? (
|
||||
<div className="flex h-6 justify-end bg-white pr-2 pt-2">
|
||||
<div className="bg-survey-bg flex h-6 justify-end pr-2 pt-2">
|
||||
<SurveyCloseButton onClose={onClose} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
36
pnpm-lock.yaml
generated
36
pnpm-lock.yaml
generated
@@ -423,6 +423,9 @@ importers:
|
||||
redis:
|
||||
specifier: 4.7.0
|
||||
version: 4.7.0
|
||||
sanitize-html:
|
||||
specifier: 2.17.0
|
||||
version: 2.17.0
|
||||
server-only:
|
||||
specifier: 0.0.1
|
||||
version: 0.0.1
|
||||
@@ -499,6 +502,9 @@ importers:
|
||||
'@types/qrcode':
|
||||
specifier: 1.5.5
|
||||
version: 1.5.5
|
||||
'@types/sanitize-html':
|
||||
specifier: 2.16.0
|
||||
version: 2.16.0
|
||||
'@types/testing-library__react':
|
||||
specifier: 10.2.0
|
||||
version: 10.2.0(@testing-library/dom@8.20.1)(@types/react-dom@19.2.1(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
@@ -4877,6 +4883,9 @@ packages:
|
||||
'@types/resolve@1.20.6':
|
||||
resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==}
|
||||
|
||||
'@types/sanitize-html@2.16.0':
|
||||
resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==}
|
||||
|
||||
'@types/semver@7.7.1':
|
||||
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
|
||||
|
||||
@@ -7411,6 +7420,10 @@ packages:
|
||||
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
is-plain-object@5.0.0:
|
||||
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-potential-custom-element-name@1.0.1:
|
||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||
|
||||
@@ -8432,6 +8445,9 @@ packages:
|
||||
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
parse-srcset@1.0.2:
|
||||
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
|
||||
|
||||
parse5@8.0.0:
|
||||
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
|
||||
|
||||
@@ -9196,6 +9212,9 @@ packages:
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
sanitize-html@2.17.0:
|
||||
resolution: {integrity: sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==}
|
||||
|
||||
satori@0.16.0:
|
||||
resolution: {integrity: sha512-ZvHN3ygzZ8FuxjSNB+mKBiF/NIoqHzlBGbD0MJiT+MvSsFOvotnWOhdTjxKzhHRT2wPC1QbhLzx2q/Y83VhfYQ==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -15891,6 +15910,10 @@ snapshots:
|
||||
|
||||
'@types/resolve@1.20.6': {}
|
||||
|
||||
'@types/sanitize-html@2.16.0':
|
||||
dependencies:
|
||||
htmlparser2: 8.0.2
|
||||
|
||||
'@types/semver@7.7.1': {}
|
||||
|
||||
'@types/shimmer@1.2.0': {}
|
||||
@@ -18924,6 +18947,8 @@ snapshots:
|
||||
|
||||
is-plain-obj@4.1.0: {}
|
||||
|
||||
is-plain-object@5.0.0: {}
|
||||
|
||||
is-potential-custom-element-name@1.0.1: {}
|
||||
|
||||
is-property@1.0.2: {}
|
||||
@@ -19972,6 +19997,8 @@ snapshots:
|
||||
json-parse-even-better-errors: 2.3.1
|
||||
lines-and-columns: 1.2.4
|
||||
|
||||
parse-srcset@1.0.2: {}
|
||||
|
||||
parse5@8.0.0:
|
||||
dependencies:
|
||||
entities: 6.0.1
|
||||
@@ -20791,6 +20818,15 @@ snapshots:
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
sanitize-html@2.17.0:
|
||||
dependencies:
|
||||
deepmerge: 4.3.1
|
||||
escape-string-regexp: 4.0.0
|
||||
htmlparser2: 8.0.2
|
||||
is-plain-object: 5.0.0
|
||||
parse-srcset: 1.0.2
|
||||
postcss: 8.5.3
|
||||
|
||||
satori@0.16.0:
|
||||
dependencies:
|
||||
'@shuding/opentype.js': 1.4.0-beta.0
|
||||
|
||||
Reference in New Issue
Block a user