Compare commits

..

3 Commits

Author SHA1 Message Date
Johannes 5b50605058 fix: await user sync completion in setUserId and setAttributes
Previously, setUserId() and setAttributes() returned immediately after
queuing the update, but the API sync (which sets contactId) happened
asynchronously with a 500ms debounce. This caused a race condition where
surveys could be displayed as anonymous if shown before the sync completed.

Changes:
- Make setUserId() and setAttributes() await processUpdates() completion
- Add queue.wait() to public API functions (setUserId, setAttribute,
  setAttributes, setEmail, setLanguage) to ensure commands execute
- Add error handling for network failures during sync
- Update tests to reflect new async behavior

This ensures that when customers await these functions, the contactId
is properly set before the promise resolves, preventing anonymous
survey responses.
2025-12-18 19:07:59 +01:00
Anshuman Pandey a736436e29 chore: fixes typo (#6993) 2025-12-18 09:25:12 +00:00
Johannes 7dbb0300d3 fix: Pass the isExternalUrlAllowed prop to welcome card (#6992) 2025-12-18 08:51:21 +00:00
60 changed files with 248 additions and 421 deletions
+2 -2
View File
@@ -1910,9 +1910,9 @@ checksums:
s/want_to_respond: fbb26054f6af3b625cb569e19063302f
setup/intro/get_started: 5c783951b0100a168bdd2161ff294833
setup/intro/made_with_love_in_kiel: 1bbdd6e93bcdf7cbfbcac16db448a2e4
setup/intro/paragraph_1: 360c902da0db044c6cc346ac18099902
setup/intro/paragraph_1: 41e6a1e7c9a4a1922c7064a89f6733fd
setup/intro/paragraph_2: 5b3cce4d8c75bab4d671e2af7fc7ee9f
setup/intro/paragraph_3: 0675e53f2f48e3a04db6e52698bdebae
setup/intro/paragraph_3: 5bf4718d4c44ff27e55e0880331f293d
setup/intro/welcome_to_formbricks: 561427153e3effa108f54407dfc2126f
setup/invite/add_another_member: 02947deaa4710893794f3cc6e160c2b4
setup/invite/continue: 3cfba90b4600131e82fc4260c568d044
+1 -1
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", "player.vimeo.com"];
const vimeoDomains = ["www.vimeo.com", "vimeo.com"];
const hostname = vimeoUrl.hostname;
return vimeoDomains.includes(hostname);
+1 -1
View File
@@ -2048,7 +2048,7 @@
"made_with_love_in_kiel": "Gebaut mit 🤍 in Deutschland",
"paragraph_1": "Formbricks ist eine Experience Management Suite, die auf der <b>am schnellsten wachsenden Open-Source-Umfrageplattform</b> weltweit basiert.",
"paragraph_2": "Führe gezielte Umfragen auf Websites, in Apps oder überall online durch. Sammle wertvolle Insights, um unwiderstehliche Erlebnisse für Kunden, Nutzer und Mitarbeiter zu gestalten.",
"paragraph_3": "Wir schreiben DATENSCHUTZ groß (ha!). Hoste Formbricks selbst, um <b>volle Kontrolle über deine Daten</b> zu behalten.",
"paragraph_3": "Wir verpflichten uns zu höchstem Datenschutz. Hosten Sie selbst, um die <b>volle Kontrolle über Ihre Daten</b> zu behalten.",
"welcome_to_formbricks": "Willkommen bei Formbricks!"
},
"invite": {
+2 -2
View File
@@ -2046,9 +2046,9 @@
"intro": {
"get_started": "Get started",
"made_with_love_in_kiel": "Made with \uD83E\uDD0D in Germany",
"paragraph_1": "Formbricks is an Experience Management Suite built of the <b>fastest growing open source survey platform</b> worldwide.",
"paragraph_1": "Formbricks is an Experience Management Suite built on the <b>fastest growing open-source survey platform</b> worldwide.",
"paragraph_2": "Run targeted surveys on websites, in apps or anywhere online. Gather valuable insights to <b>craft irresistible experiences</b> for customers, users and employees.",
"paragraph_3": "We're commited to highest degree of data privacy. Self-host to keep <b>full control over your data</b>.",
"paragraph_3": "We're committed to the highest degree of data privacy. Self-host to keep <b>full control over your data</b>.",
"welcome_to_formbricks": "Welcome to Formbricks!"
},
"invite": {
+2 -2
View File
@@ -2046,9 +2046,9 @@
"intro": {
"get_started": "Comenzar",
"made_with_love_in_kiel": "Hecho con 🤍 en Alemania",
"paragraph_1": "Formbricks es una Suite de Gestión de Experiencia construida sobre la <b>plataforma de encuestas de código abierto de más rápido crecimiento</b> en todo el mundo.",
"paragraph_1": "Formbricks es una suite de gestión de experiencias construida sobre la <b>plataforma de encuestas de código abierto de más rápido crecimiento</b> a nivel mundial.",
"paragraph_2": "Realiza encuestas dirigidas en sitios web, en aplicaciones o en cualquier lugar online. Recopila información valiosa para <b>crear experiencias irresistibles</b> para clientes, usuarios y empleados.",
"paragraph_3": "Estamos comprometidos con el más alto grado de privacidad de datos. Alójalo tú mismo para mantener <b>control total sobre tus datos</b>.",
"paragraph_3": "Estamos comprometidos con el más alto grado de privacidad de datos. Aloja en tu propio servidor para mantener el <b>control total sobre tus datos</b>.",
"welcome_to_formbricks": "¡Bienvenido a Formbricks!"
},
"invite": {
+2 -2
View File
@@ -2046,9 +2046,9 @@
"intro": {
"get_started": "Commencer",
"made_with_love_in_kiel": "Fabriqué avec 🤍 en Allemagne",
"paragraph_1": "Formbricks est une suite de gestion de l'expérience construite sur la <b>plateforme d'enquête open source à la croissance la plus rapide</b> au monde.",
"paragraph_1": "Formbricks est une suite de gestion de l'expérience construite sur la <b>plateforme de sondage open-source à la croissance la plus rapide</b> au monde.",
"paragraph_2": "Réalisez des enquêtes ciblées sur des sites web, dans des applications ou partout en ligne. Collectez des informations précieuses pour <b>créer des expériences irrésistibles</b> pour les clients, les utilisateurs et les employés.",
"paragraph_3": "Nous sommes engagés à garantir le plus haut niveau de confidentialité des données. Auto-hébergez pour garder <b>le contrôle total sur vos données</b>. Toujours.",
"paragraph_3": "Nous nous engageons à respecter le plus haut degré de confidentialité des données. Auto-hébergez pour garder <b>le contrôle total de vos données</b>.",
"welcome_to_formbricks": "Bienvenue sur Formbricks !"
},
"invite": {
+2 -2
View File
@@ -2046,9 +2046,9 @@
"intro": {
"get_started": "始める",
"made_with_love_in_kiel": "キールで愛を込めて作られました 🤍",
"paragraph_1": "Formbricksは、世界で<b>最も急速に成長しているオープンソースのフォームプラットフォーム</b>から構築されたエクスペリエンス管理スイートです。",
"paragraph_1": "Formbricksは、世界で<b>最も急成長しているオープンソースのアンケートプラットフォーム</b>をベースに構築されたエクスペリエンス管理スイートです。",
"paragraph_2": "ウェブサイト、アプリ、またはオンラインのどこでもターゲットを絞ったフォームを実行できます。貴重な洞察を収集して、顧客、ユーザー、従業員向けの<b>魅力的な体験</b>を作り出します。",
"paragraph_3": "私たちは最高のデータプライバシーを約束します。セルフホストして、<b>データを完全に制御</b>できます。",
"paragraph_3": "私たちは最高レベルのデータプライバシーを重視しています。セルフホスティングにより、<b>データを完全に管理</b>できます。",
"welcome_to_formbricks": "Formbricksへようこそ!"
},
"invite": {
+2 -2
View File
@@ -2046,9 +2046,9 @@
"intro": {
"get_started": "Ga aan de slag",
"made_with_love_in_kiel": "Gemaakt met 🤍 in Duitsland",
"paragraph_1": "Formbricks is een Experience Management Suite die is gebouwd op het <b>snelst groeiende open source enquêteplatform</b> wereldwijd.",
"paragraph_1": "Formbricks is een Experience Management Suite gebouwd op het <b>snelst groeiende open-source enquêteplatform</b> wereldwijd.",
"paragraph_2": "Voer gerichte enquêtes uit op websites, in apps of waar dan ook online. Verzamel waardevolle inzichten om <b>onweerstaanbare ervaringen te creëren</b> voor klanten, gebruikers en medewerkers.",
"paragraph_3": "We streven naar de hoogste mate van gegevensprivacy. Zelfhosting om <b>volledige controle over uw gegevens</b> te behouden.",
"paragraph_3": "We zijn toegewijd aan de hoogste mate van gegevensprivacy. Self-host om <b>volledige controle over je gegevens</b> te behouden.",
"welcome_to_formbricks": "Welkom bij Formbricks!"
},
"invite": {
+2 -2
View File
@@ -2046,9 +2046,9 @@
"intro": {
"get_started": "Começar",
"made_with_love_in_kiel": "Feito com 🤍 em Alemanha",
"paragraph_1": "Formbricks é uma suíte de gerenciamento de experiência construída na <b>plataforma de pesquisa open source que mais cresce</b> no mundo.",
"paragraph_1": "Formbricks é uma suíte de gerenciamento de experiência construída sobre a <b>plataforma de pesquisa de código aberto de crescimento mais rápido</b> do mundo.",
"paragraph_2": "Faça pesquisas direcionadas em sites, apps ou em qualquer lugar online. Recolha insights valiosos para criar experiências irresistíveis para clientes, usuários e funcionários.",
"paragraph_3": "Estamos comprometidos com o mais alto nível de privacidade de dados. Hospede você mesmo para manter <b>controle total sobre seus dados</b>. Sempre",
"paragraph_3": "Estamos comprometidos com o mais alto grau de privacidade de dados. Hospede você mesmo para manter <b>controle total sobre seus dados</b>.",
"welcome_to_formbricks": "Bem-vindo ao Formbricks!"
},
"invite": {
+2 -2
View File
@@ -2046,9 +2046,9 @@
"intro": {
"get_started": "Começar",
"made_with_love_in_kiel": "Feito com 🤍 na Alemanha",
"paragraph_1": "Formbricks é uma Suite de Gestão de Experiência construída na <b>plataforma de inquéritos de código aberto de crescimento mais rápido</b> do mundo.",
"paragraph_1": "Formbricks é uma Suite de Gestão de Experiência construída na <b>plataforma de inquéritos open-source de crescimento mais rápido</b> a nível mundial.",
"paragraph_2": "Execute inquéritos direcionados em websites, em apps ou em qualquer lugar online. Recolha informações valiosas para <b>criar experiências irresistíveis</b> para clientes, utilizadores e funcionários.",
"paragraph_3": "Estamos comprometidos com o mais alto grau de privacidade de dados. Auto-hospede para manter <b>controlo total sobre os seus dados</b>.",
"paragraph_3": "Estamos comprometidos com o mais alto grau de privacidade de dados. Faça self-host para manter <b>controlo total sobre os seus dados</b>.",
"welcome_to_formbricks": "Bem-vindo ao Formbricks!"
},
"invite": {
+2 -2
View File
@@ -2046,9 +2046,9 @@
"intro": {
"get_started": "Începeți",
"made_with_love_in_kiel": "Creat cu 🤍 în Germania",
"paragraph_1": "Formbricks este o suită de management al experiențelor construită pe baza <b>platformei de sondaje open source care crește cel mai rapid</b> din lume.",
"paragraph_1": "Formbricks este o suită de management al experienței construită pe <b>cea mai rapidă platformă open-source de sondaje</b> din lume.",
"paragraph_2": "Rulați sondaje direcționate pe site-uri web, în aplicații sau oriunde online. Adunați informații valoroase pentru a <b>crea experiențe irezistibile</b> pentru clienți, utilizatori și angajați.",
"paragraph_3": "Suntem angajați la cel mai înalt grad de confidențialitate a datelor. Găzduirea proprie vă oferă <b>control deplin asupra datelor dumneavoastră</b>.",
"paragraph_3": "Suntem dedicați celui mai înalt nivel de confidențialitate a datelor. Găzduiește local pentru a păstra <b>controlul deplin asupra datelor tale</b>.",
"welcome_to_formbricks": "Bine ai venit la Formbricks!"
},
"invite": {
+2 -2
View File
@@ -2046,9 +2046,9 @@
"intro": {
"get_started": "Kom igång",
"made_with_love_in_kiel": "Gjort med 🤍 i Tyskland",
"paragraph_1": "Formbricks är en Experience Management Suite byggd av den <b>snabbast växande öppenkällkods enkätplattformen</b> i världen.",
"paragraph_1": "Formbricks är en Experience Management Suite byggd den <b>snabbast växande open source-enkätplattformen</b> i världen.",
"paragraph_2": "Kör riktade enkäter på webbplatser, i appar eller var som helst online. Samla värdefulla insikter för att <b>skapa oemotståndliga upplevelser</b> för kunder, användare och anställda.",
"paragraph_3": "Vi är engagerade i högsta grad av dataintegritet. Självhosta för att behålla <b>full kontroll över dina data</b>.",
"paragraph_3": "Vi är engagerade i högsta möjliga datasekretess. Självhosta för att behålla <b>full kontroll över dina data</b>.",
"welcome_to_formbricks": "Välkommen till Formbricks!"
},
"invite": {
+2 -2
View File
@@ -2046,9 +2046,9 @@
"intro": {
"get_started": "开始使用",
"made_with_love_in_kiel": "以 🤍 在 德国 制作",
"paragraph_1": "Formbricks 是一体验管理套件, 基于全球<b>增长最快的开源调平台</b>构建。",
"paragraph_1": "Formbricks 是一体验管理套件,基于全球<b>增长最快的开源调平台</b>构建。",
"paragraph_2": "在网站、应用程序或任何在线平台上运行 定向 调查。收集 有价值 的见解,为客户、用户和员工<b>打造 无法抗拒 的体验</b>。",
"paragraph_3": "我们致力于最高级别的数据隐私。 自行托管以保持<b>对您的数据的完全控制</b>。",
"paragraph_3": "我们致力于最高级别的数据隐私保护。自建部署,<b>全面掌控您的数据</b>。",
"welcome_to_formbricks": "欢迎来到 Formbricks !"
},
"invite": {
+2 -2
View File
@@ -2046,9 +2046,9 @@
"intro": {
"get_started": "開始使用",
"made_with_love_in_kiel": "用 🤍 在德國製造",
"paragraph_1": "Formbricks 是一套體驗管理套件,建於全球<b>成長最快的開源問卷平台</b>之上。",
"paragraph_1": "Formbricks 是一套體驗管理工具,建於全球<b>成長最快的開源問卷平台</b>之上。",
"paragraph_2": "在網站、應用程式或線上任何地方執行目標問卷。收集寶貴的洞察,為客戶、使用者和員工<b>打造無法抗拒的體驗</b>。",
"paragraph_3": "我們致力於最高程度的資料隱私。自託管<b>完全掌控您的資料</b>。",
"paragraph_3": "我們致力於最高等級的資料隱私。自託管,讓您<b>完全掌控您的資料</b>。",
"welcome_to_formbricks": "歡迎使用 Formbricks"
},
"invite": {
@@ -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,16 +35,11 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JS
<>
<div
dangerouslySetInnerHTML={{
__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"],
},
__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)
}),
}}
/>
@@ -86,10 +86,6 @@ 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";
+2 -4
View File
@@ -72,8 +72,8 @@
"@radix-ui/react-tooltip": "1.2.6",
"@react-email/components": "0.0.38",
"@sentry/nextjs": "10.5.0",
"@t3-oss/env-nextjs": "0.13.4",
"@tailwindcss/forms": "0.5.10",
"@t3-oss/env-nextjs": "0.13.4",
"@tailwindcss/typography": "0.5.16",
"@tanstack/react-table": "8.21.3",
"@ungap/structured-clone": "1.3.0",
@@ -111,17 +111,16 @@
"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",
@@ -149,7 +148,6 @@
"@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",
+5
View File
@@ -42,22 +42,27 @@ const setup = async (setupConfig: TConfigInput): Promise<void> => {
const setUserId = async (userId: string): Promise<void> => {
await queue.add(User.setUserId, CommandType.UserAction, true, userId);
await queue.wait();
};
const setEmail = async (email: string): Promise<void> => {
await queue.add(Attribute.setAttributes, CommandType.UserAction, true, { email });
await queue.wait();
};
const setAttribute = async (key: string, value: string): Promise<void> => {
await queue.add(Attribute.setAttributes, CommandType.UserAction, true, { [key]: value });
await queue.wait();
};
const setAttributes = async (attributes: Record<string, string>): Promise<void> => {
await queue.add(Attribute.setAttributes, CommandType.UserAction, true, attributes);
await queue.wait();
};
const setLanguage = async (language: string): Promise<void> => {
await queue.add(Attribute.setAttributes, CommandType.UserAction, true, { language });
await queue.wait();
};
const logout = async (): Promise<void> => {
+17 -4
View File
@@ -1,12 +1,25 @@
import { Logger } from "@/lib/common/logger";
import { UpdateQueue } from "@/lib/user/update-queue";
import { type NetworkError, type Result, okVoid } from "@/types/error";
import { type NetworkError, type Result, err, okVoid } from "@/types/error";
export const setAttributes = async (
attributes: Record<string, string>
// eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here
): Promise<Result<void, NetworkError>> => {
const logger = Logger.getInstance();
const updateQueue = UpdateQueue.getInstance();
updateQueue.updateAttributes(attributes);
void updateQueue.processUpdates();
return okVoid();
try {
await updateQueue.processUpdates();
return okVoid();
} catch (error) {
logger.error(
`Failed to process attribute updates: ${error instanceof Error ? error.message : "Unknown error"}`
);
return err({
code: "network_error",
message: "Failed to sync attributes",
responseMessage: error instanceof Error ? error.message : "Unknown error",
status: 500,
});
}
};
@@ -17,6 +17,16 @@ vi.mock("@/lib/user/update-queue", () => ({
},
}));
// Mock the Logger
vi.mock("@/lib/common/logger", () => ({
Logger: {
getInstance: vi.fn(() => ({
error: vi.fn(),
debug: vi.fn(),
})),
},
}));
describe("User Attributes", () => {
const mockUpdateQueue = {
updateAttributes: vi.fn(),
@@ -32,6 +42,8 @@ describe("User Attributes", () => {
describe("setAttributes", () => {
test("successfully updates attributes and triggers processing", async () => {
mockUpdateQueue.processUpdates.mockResolvedValue(undefined);
const result = await setAttributes(mockAttributes);
// Verify UpdateQueue methods were called correctly
@@ -43,6 +55,8 @@ describe("User Attributes", () => {
});
test("processes multiple attribute updates", async () => {
mockUpdateQueue.processUpdates.mockResolvedValue(undefined);
const firstAttributes = { name: mockAttributes.name };
const secondAttributes = { email: mockAttributes.email };
@@ -55,22 +69,35 @@ describe("User Attributes", () => {
expect(mockUpdateQueue.processUpdates).toHaveBeenCalledTimes(2);
});
test("processes updates asynchronously", async () => {
test("waits for processUpdates to complete", async () => {
const attributes = { name: mockAttributes.name };
let processUpdatesResolved = false;
// Mock processUpdates to be async
// Mock processUpdates to be async and set a flag when resolved
mockUpdateQueue.processUpdates.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(resolve, 100);
setTimeout(() => {
processUpdatesResolved = true;
resolve(undefined);
}, 100);
})
);
const result = await setAttributes(attributes);
const resultPromise = setAttributes(attributes);
expect(result.ok).toBe(true);
// Verify processUpdates was called
expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
// The function returns before processUpdates completes due to void operator
// Verify the function hasn't resolved yet
expect(processUpdatesResolved).toBe(false);
// Wait for setAttributes to complete
const result = await resultPromise;
// Verify it completed after processUpdates
expect(processUpdatesResolved).toBe(true);
expect(result.ok).toBe(true);
});
});
});
@@ -101,7 +101,7 @@ describe("user.ts", () => {
const mockUpdateQueue = {
updateUserId: vi.fn(),
processUpdates: vi.fn(),
processUpdates: vi.fn().mockResolvedValue(undefined),
};
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
@@ -113,6 +113,42 @@ describe("user.ts", () => {
expect(mockUpdateQueue.updateUserId).toHaveBeenCalledWith(mockUserId);
expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
});
test("returns error if processUpdates fails", async () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
user: {
data: {
userId: null,
},
},
}),
};
const mockLogger = {
debug: vi.fn(),
error: vi.fn(),
};
const mockUpdateQueue = {
updateUserId: vi.fn(),
processUpdates: vi.fn().mockRejectedValue(new Error("Network error")),
};
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
getInstanceLoggerMock.mockReturnValue(mockLogger as unknown as Logger);
getInstanceUpdateQueueMock.mockReturnValue(mockUpdateQueue as unknown as UpdateQueue);
const result = await setUserId(mockUserId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("network_error");
expect(result.error.status).toBe(500);
}
expect(mockUpdateQueue.updateUserId).toHaveBeenCalledWith(mockUserId);
expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
expect(mockLogger.error).toHaveBeenCalled();
});
});
describe("logout", () => {
+14 -3
View File
@@ -4,7 +4,6 @@ import { tearDown } from "@/lib/common/setup";
import { UpdateQueue } from "@/lib/user/update-queue";
import { type ApiErrorResponse, type Result, err, okVoid } from "@/types/error";
// eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here
export const setUserId = async (userId: string): Promise<Result<void, ApiErrorResponse>> => {
const appConfig = Config.getInstance();
const logger = Logger.getInstance();
@@ -27,8 +26,20 @@ export const setUserId = async (userId: string): Promise<Result<void, ApiErrorRe
}
updateQueue.updateUserId(userId);
void updateQueue.processUpdates();
return okVoid();
try {
await updateQueue.processUpdates();
return okVoid();
} catch (error) {
logger.error(
`Failed to process userId update: ${error instanceof Error ? error.message : "Unknown error"}`
);
return err({
code: "network_error",
message: "Failed to sync userId",
responseMessage: error instanceof Error ? error.message : "Unknown error",
status: 500,
});
}
};
export const logout = (): Result<void> => {
@@ -30,10 +30,6 @@ 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({
@@ -48,8 +44,6 @@ function Consent({
errorMessage,
dir = "auto",
disabled = false,
imageUrl,
videoUrl,
}: Readonly<ConsentProps>): React.JSX.Element {
const handleCheckboxChange = (checked: boolean): void => {
if (disabled) return;
@@ -59,14 +53,7 @@ function Consent({
return (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
{/* Consent Checkbox */}
<div className="relative space-y-2">
@@ -87,7 +74,7 @@ function Consent({
onCheckedChange={handleCheckboxChange}
disabled={disabled}
aria-invalid={Boolean(errorMessage)}
required={required}
aria-required={required}
/>
{/* need to use style here because tailwind is not able to use css variables for font size and weight */}
<span
@@ -34,10 +34,6 @@ 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({
@@ -54,8 +50,6 @@ function CTA({
dir = "auto",
disabled = false,
buttonVariant = "default",
imageUrl,
videoUrl,
}: Readonly<CTAProps>): React.JSX.Element {
const handleButtonClick = (): void => {
if (disabled) return;
@@ -69,33 +63,24 @@ function CTA({
return (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
{/* CTA Button */}
<div className="relative space-y-2">
<ElementError errorMessage={errorMessage} dir={dir} />
{buttonExternal && (
<div className="flex w-full justify-start">
<Button
id={inputId}
type="button"
onClick={handleButtonClick}
disabled={disabled}
className="flex items-center gap-2"
variant={buttonVariant}>
{buttonLabel}
<SquareArrowOutUpRightIcon className="size-4" />
</Button>
</div>
)}
<div className="flex w-full justify-start">
<Button
id={inputId}
type="button"
onClick={handleButtonClick}
disabled={disabled}
className="flex items-center gap-2"
variant={buttonVariant}>
{buttonLabel}
{buttonExternal ? <SquareArrowOutUpRightIcon className="size-4" /> : null}
</Button>
</div>
</div>
</div>
);
@@ -1,6 +1,5 @@
import * as React from "react";
import { Calendar } from "@/components/general/calendar";
import { ElementError } from "@/components/general/element-error";
import { ElementHeader } from "@/components/general/element-header";
import { getDateFnsLocale } from "@/lib/locale";
@@ -31,10 +30,6 @@ 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({
@@ -50,9 +45,6 @@ function DateElement({
dir = "auto",
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>(() => {
@@ -95,86 +87,58 @@ function DateElement({
}
};
// 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]);
// 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;
const checkAtMidnight = new Date(
dateToCheck.getFullYear(),
dateToCheck.getMonth(),
dateToCheck.getDate()
);
if (startMonth) {
const minAtMidnight = new Date(startMonth.getFullYear(), startMonth.getMonth(), startMonth.getDate());
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 (endMonth) {
const maxAtMidnight = new Date(endMonth.getFullYear(), endMonth.getMonth(), endMonth.getDate());
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, endMonth, startMonth]
[disabled, minDateObj, maxDateObj]
);
// Get locale for date formatting
const dateLocale = React.useMemo(() => {
return locale ? getDateFnsLocale(locale) : undefined;
}, [locale]);
return (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
{/* Calendar - Always visible */}
<div className="w-full">
<Calendar
mode="single"
selected={date}
defaultMonth={date}
captionLayout="dropdown"
startMonth={startMonth}
endMonth={endMonth}
disabled={isDateDisabled}
onSelect={handleDateSelect}
locale={dateLocale}
required={required}
className="rounded-input border-input-border bg-input-bg text-input-text shadow-input mx-auto w-full max-w-[25rem] border"
/>
</div>
{/* Calendar - Always visible */}
<div className="w-full">
<Calendar
mode="single"
selected={date}
captionLayout="dropdown"
disabled={isDateDisabled}
onSelect={handleDateSelect}
locale={dateLocale}
className="rounded-input border-input-border bg-input-bg text-input-text shadow-input mx-auto w-full max-w-[25rem] border"
/>
</div>
</div>
);
@@ -236,6 +236,7 @@ export const MultipleElements: Story = {
description="You can upload multiple images"
allowMultiple
allowedFileExtensions={[".jpg", ".png", ".gif"]}
maxSizeInMB={5}
onChange={() => {}}
/>
</div>
@@ -2,6 +2,7 @@ import { Upload, UploadIcon, X } from "lucide-react";
import * as React from "react";
import { ElementError } from "@/components/general/element-error";
import { ElementHeader } from "@/components/general/element-header";
import { Input } from "@/components/general/input";
import { cn } from "@/lib/utils";
/**
@@ -192,11 +193,11 @@ function UploadArea({
id={`${inputId}-label`}>
{placeholderText}
</span>
<input
<Input
ref={fileInputRef}
type="file"
id={inputId}
className="sr-only"
className="hidden"
multiple={allowMultiple}
accept={acceptAttribute}
onChange={onFileChange}
@@ -43,10 +43,6 @@ 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({
@@ -60,8 +56,6 @@ function FormField({
errorMessage,
dir = "auto",
disabled = false,
imageUrl,
videoUrl,
}: Readonly<FormFieldProps>): React.JSX.Element {
// Ensure value is always an object
const currentValues = React.useMemo(() => {
@@ -99,13 +93,7 @@ function FormField({
return (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
<ElementHeader headline={headline} description={description} required={required} />
{/* Form Fields */}
<div className="relative space-y-3">
@@ -41,10 +41,6 @@ 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({
@@ -60,8 +56,6 @@ 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;
@@ -84,14 +78,7 @@ function Matrix({
return (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
{/* Matrix Table */}
<div className="relative">
@@ -127,7 +114,6 @@ function Matrix({
onValueChange={(newColumnId) => {
handleRowChange(row.id, newColumnId);
}}
name={rowGroupId}
disabled={disabled}
required={required}
aria-invalid={Boolean(errorMessage)}>
@@ -156,7 +142,6 @@ function Matrix({
<Label htmlFor={cellId} className="flex cursor-pointer justify-center">
<RadioGroupItem
value={column.id}
required={required}
id={cellId}
disabled={disabled}
aria-label={`${row.label}-${column.label}`}
@@ -67,10 +67,6 @@ 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
@@ -141,7 +137,7 @@ function DropdownVariant({
const isRequired = getIsRequired();
const handleOptionToggle = (optionId: string): void => {
const handleOptionToggle = (optionId: string) => {
if (selectedValues.includes(optionId)) {
handleOptionRemove(optionId);
} else {
@@ -444,8 +440,6 @@ function MultiSelect({
otherValue = "",
onOtherValueChange,
exclusiveOptionIds = [],
imageUrl,
videoUrl,
}: Readonly<MultiSelectProps>): React.JSX.Element {
// Ensure value is always an array
const selectedValues = Array.isArray(value) ? value : [];
@@ -497,14 +491,7 @@ function MultiSelect({
return (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
{/* Options */}
<div className="relative">
@@ -31,10 +31,6 @@ 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({
@@ -51,8 +47,6 @@ function NPS({
errorMessage,
dir = "auto",
disabled = false,
imageUrl,
videoUrl,
}: Readonly<NPSProps>): React.JSX.Element {
const [hoveredValue, setHoveredValue] = React.useState<number | null>(null);
@@ -168,14 +162,7 @@ function NPS({
return (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
{/* NPS Options */}
<div className="relative space-y-2">
@@ -24,8 +24,6 @@ interface OpenTextProps {
dir?: "ltr" | "rtl" | "auto";
rows?: number;
disabled?: boolean;
imageUrl?: string;
videoUrl?: string;
}
function OpenText({
@@ -44,8 +42,6 @@ function OpenText({
dir = "auto",
rows = 3,
disabled = false,
imageUrl,
videoUrl,
}: Readonly<OpenTextProps>): React.JSX.Element {
const [currentLength, setCurrentLength] = useState(value.length);
@@ -68,14 +64,7 @@ function OpenText({
return (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
{/* Input or Textarea */}
<div className="relative space-y-2">
@@ -43,10 +43,6 @@ 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({
@@ -62,8 +58,6 @@ 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;
@@ -92,14 +86,7 @@ function PictureSelect({
return (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
{/* Picture Grid - 2 columns */}
<div className="relative">
@@ -43,10 +43,6 @@ 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 {
@@ -78,14 +74,7 @@ function getBottomButtonRadiusClass(isLast: boolean, dir?: TextDirection): strin
return "rounded-br-md";
}
function RankingItem({
item,
rankedIds,
onItemClick,
onMove,
disabled,
dir,
}: Readonly<RankingItemProps>): React.ReactNode {
function RankingItem({ item, rankedIds, onItemClick, onMove, disabled, dir }: Readonly<RankingItemProps>) {
const isRanked = rankedIds.includes(item.id);
const rankIndex = rankedIds.indexOf(item.id);
const isFirst = isRanked && rankIndex === 0;
@@ -194,8 +183,6 @@ 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]);
@@ -245,14 +232,7 @@ function Ranking({
return (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
{/* Ranking Options */}
<div className="relative">
@@ -143,10 +143,6 @@ 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({
@@ -165,8 +161,6 @@ function Rating({
errorMessage,
dir = "auto",
disabled = false,
imageUrl,
videoUrl,
}: Readonly<RatingProps>): React.JSX.Element {
const [hoveredValue, setHoveredValue] = React.useState<number | null>(null);
@@ -405,14 +399,7 @@ function Rating({
return (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
{/* Rating Options */}
<div className="relative space-y-2">
@@ -61,10 +61,6 @@ 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({
@@ -86,8 +82,6 @@ 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;
@@ -137,14 +131,7 @@ function SingleSelect({
return (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
{/* Options */}
<div className="space-y-3">
@@ -4,7 +4,7 @@ import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-button text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
@@ -60,8 +60,6 @@ function Calendar({
formatters,
components,
locale,
startMonth,
endMonth,
...props
}: Readonly<
React.ComponentProps<typeof DayPicker> & {
@@ -80,22 +78,6 @@ 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}
@@ -107,8 +89,6 @@ function Calendar({
)}
captionLayout={captionLayout}
locale={resolvedLocale}
startMonth={resolvedStartMonth}
endMonth={resolvedEndMonth}
formatters={{
formatMonthDropdown: (date) => {
if (resolvedLocale) {
@@ -176,7 +156,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-brand-foreground rounded-md data-[selected=true]:rounded-none bg-brand opacity-50",
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none bg-brand opacity-50",
defaultClassNames.today
),
outside: cn(
@@ -3,18 +3,30 @@ 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 => {
// 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;
const isYoutubeVideo = checkForYoutubeUrl(videoUrl);
const isVimeoUrl = checkForVimeoUrl(videoUrl);
const isLoomUrl = checkForLoomUrl(videoUrl);
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;
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;
};
interface ElementMediaProps {
@@ -23,12 +35,16 @@ interface ElementMediaProps {
altText?: string;
}
function ElementMedia({ imgUrl, videoUrl, altText = "Image" }: Readonly<ElementMediaProps>): React.ReactNode {
function ElementMedia({
imgUrl,
videoUrl,
altText = "Image",
}: Readonly<ElementMediaProps>): React.JSX.Element {
const videoUrlWithParams = videoUrl ? getVideoUrlWithParams(videoUrl) : undefined;
const [isLoading, setIsLoading] = React.useState(true);
if (!imgUrl && !videoUrl) {
return null;
return <></>;
}
return (
+1 -1
View File
@@ -84,7 +84,7 @@ const extractVimeoId = (url: string): string | null => {
};
const extractLoomId = (url: string): string | null => {
const regExp = /loom\.com\/(?:share|embed)\/(?<videoId>[a-zA-Z0-9]+)/;
const regExp = /loom\.com\/share\/(?<videoId>[a-zA-Z0-9]+)/;
const match = regExp.exec(url);
return match?.groups?.videoId ?? null;
@@ -119,8 +119,6 @@ export function AddressElement({
onChange={handleChange}
required={element.required}
dir={dir}
imageUrl={element.imageUrl}
videoUrl={element.videoUrl}
/>
</form>
);
@@ -53,8 +53,6 @@ export function ConsentElement({
onChange={handleChange}
required={element.required}
dir={dir}
imageUrl={element.imageUrl}
videoUrl={element.videoUrl}
/>
</form>
);
@@ -115,8 +115,6 @@ export function ContactInfoElement({
onChange={handleChange}
required={element.required}
dir={dir}
imageUrl={element.imageUrl}
videoUrl={element.videoUrl}
/>
</form>
);
@@ -65,8 +65,6 @@ export function CTAElement({
onClick={handleClick}
// CTA cannot be required
required={false}
imageUrl={element.imageUrl}
videoUrl={element.videoUrl}
/>
</form>
);
@@ -56,12 +56,13 @@ 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 new Date(new Date().getFullYear() + 100, 0, 1).toISOString().split("T")[0];
return "3000-12-31";
};
return (
@@ -78,8 +79,6 @@ export function DateElement({
required={element.required}
errorMessage={errorMessage}
locale={languageCode}
imageUrl={element.imageUrl}
videoUrl={element.videoUrl}
/>
</form>
);
@@ -134,8 +134,6 @@ export function MatrixElement({
value={convertValueToIds(value)}
onChange={handleChange}
required={element.required}
imageUrl={element.imageUrl}
videoUrl={element.videoUrl}
/>
</form>
);
@@ -258,8 +258,6 @@ export function MultipleChoiceMultiElement({
otherValue={otherValue}
onOtherValueChange={handleOtherValueChange}
exclusiveOptionIds={noneOption ? [noneOption.id] : []}
imageUrl={element.imageUrl}
videoUrl={element.videoUrl}
/>
</form>
);
@@ -175,8 +175,6 @@ export function MultipleChoiceSingleElement({
}
otherValue={otherValue}
onOtherValueChange={handleOtherValueChange}
imageUrl={element.imageUrl}
videoUrl={element.videoUrl}
/>
</form>
);
@@ -58,8 +58,6 @@ export function NPSElement({
colorCoding={element.isColorCodingEnabled}
required={element.required}
dir={dir}
imageUrl={element.imageUrl}
videoUrl={element.videoUrl}
/>
</form>
);
@@ -129,8 +129,6 @@ export function OpenTextElement({
errorMessage={errorMessage}
dir={dir}
rows={3}
imageUrl={element.imageUrl}
videoUrl={element.videoUrl}
/>
</form>
);
@@ -98,8 +98,6 @@ export function PictureSelectionElement({
required={element.required}
dir={dir}
errorMessage={errorMessage}
imageUrl={element.imageUrl}
videoUrl={element.videoUrl}
/>
</form>
);
@@ -137,8 +137,6 @@ export function RankingElement({
onChange={handleChange}
required={element.required}
errorMessage={errorMessage}
imageUrl={element.imageUrl}
videoUrl={element.videoUrl}
/>
</form>
);
@@ -59,8 +59,6 @@ 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 ? "bg-survey-bg sticky bottom-0" : ""
fullSizeCards ? "sticky bottom-0 bg-white" : ""
)}>
<div>
<SubmitButton
@@ -55,7 +55,7 @@ export function CalEmbed({ element, onSuccessfulBooking }: CalEmbedProps) {
return (
<div className="relative mt-4 overflow-auto">
<div id="cal-embed" className={cn("border-border rounded-input border")} />
<div id="cal-embed" className={cn("border-border rounded-lg border")} />
</div>
);
}
@@ -21,12 +21,9 @@ export function ErrorComponent({ errorType }: ErrorComponentProps) {
const error = errorData[errorType];
return (
<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 leading-6 font-bold">{error.title}</span>
<p className="max-w-lg text-sm leading-6 font-normal">{error.message}</p>
<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 leading-6 font-bold text-slate-900">{error.title}</span>
<p className="max-w-lg text-sm leading-6 font-normal text-slate-600">{error.message}</p>
</div>
);
}
@@ -19,9 +19,11 @@ export function ResponseErrorComponent({
}: ResponseErrorComponentProps) {
const { t } = useTranslation();
return (
<div className="bg-survey-bg text-heading flex flex-col p-4">
<span className="mb-1.5 text-base leading-6 font-bold">{t("common.your_feedback_is_stuck")}</span>
<p className="max-w-md text-sm leading-6 font-normal">
<div className="flex flex-col bg-white p-4">
<span className="mb-1.5 text-base leading-6 font-bold text-slate-900">
{t("common.your_feedback_is_stuck")}
</span>
<p className="max-w-md text-sm leading-6 font-normal text-slate-600">
{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="bg-survey-bg flex h-6 justify-end pt-2 pr-2">
<div className="flex h-6 justify-end bg-white pt-2 pr-2">
<SurveyCloseButton onClose={onClose} />
</div>
) : null}
-3
View File
@@ -125,9 +125,6 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
appendCssVariable("survey-background-color", styling.cardBackgroundColor?.light);
appendCssVariable("survey-border-color", styling.cardBorderColor?.light);
appendCssVariable("border-radius", `${Number(roundness).toString()}px`);
appendCssVariable("input-border-radius", `${Number(roundness).toString()}px`);
appendCssVariable("option-border-radius", `${Number(roundness).toString()}px`);
appendCssVariable("button-border-radius", `${Number(roundness).toString()}px`);
appendCssVariable("input-background-color", styling.inputColor?.light);
appendCssVariable("input-bg-color", styling.inputColor?.light);
appendCssVariable("option-bg-color", styling.inputColor?.light);
-36
View File
@@ -423,9 +423,6 @@ 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
@@ -502,9 +499,6 @@ 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)
@@ -4883,9 +4877,6 @@ 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==}
@@ -7420,10 +7411,6 @@ 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==}
@@ -8444,9 +8431,6 @@ 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==}
@@ -9211,9 +9195,6 @@ 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'}
@@ -15909,10 +15890,6 @@ 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': {}
@@ -18946,8 +18923,6 @@ 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: {}
@@ -19996,8 +19971,6 @@ 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
@@ -20817,15 +20790,6 @@ 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