Compare commits

...

21 Commits

Author SHA1 Message Date
Johannes
902b8c92e2 move to blocks structure, add versioning to exports for better backwards compitability 2025-12-08 22:22:59 +01:00
Johannes
17ba0f21af Merge branch 'main' of https://github.com/formbricks/formbricks into feat/import-export 2025-12-08 14:16:10 +01:00
Matti Nannt
eb92392ed1 fix: add node-forge security override to resolve Dependabot #230 (#6948) 2025-12-08 12:34:36 +00:00
dependabot[bot]
7412b32526 chore(deps): bump the npm_and_yarn group across 2 directories with 1 update (#6928)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2025-12-04 13:40:52 +00:00
Matti Nannt
193346a70d fix: upgrade Next.js to 15.5.7 and React to 19.1.2 to fix CVE-2025-66478 and CVE-2025-55182 (#6943) 2025-12-04 10:50:04 +00:00
Johannes
a1d4754b04 feat: allow survey-level logo override in styling tab (#6887)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-04 08:51:56 +00:00
Johannes
f4b918a4b6 feat: add survey metadata to webhook payload (#6939)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-04 07:08:42 +00:00
Dhruwang Jariwala
fb9a0b197a fix: disable keyboard navigation for 'other' option in multiple-choice component (#6941) 2025-12-04 06:59:13 +00:00
Dhruwang Jariwala
95b6c16dd1 fix: truncate language switch text #6910 (#6934)
Co-authored-by: Mahadeva Peruka <97960828+mahadevaperuka@users.noreply.github.com>
2025-12-03 13:40:26 +00:00
Johannes
cfdf09650f fix: error message in rating Question (#6909)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2025-12-03 09:15:34 +00:00
Anshuman Pandey
4c94fc25ae fix: fixes pnpm i18n script to generate surveys package translations as well (#6930) 2025-12-02 09:56:35 +00:00
Johannes
ccf501d925 fix: keyboard nav for MQP with multiple questions (#6926)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-02 06:40:30 +00:00
Dhruwang Jariwala
04dfbe0777 fix: removed unused t wrapper (#6923) 2025-12-01 16:35:13 +00:00
Matti Nannt
cbf255ab0d docs: add custom subpath deployment guide (#6922) 2025-12-01 15:33:51 +01:00
Dhruwang Jariwala
942366956c fix: missing finish label on last card (#6915) 2025-12-01 13:50:49 +00:00
Johannes
a384743751 surface errors in UI 2025-11-26 16:27:19 +01:00
Johannes
dfa1c3e375 Merge branch 'main' of https://github.com/formbricks/formbricks into feat/import-export 2025-11-26 14:35:46 +01:00
Johannes
77c9302183 Code Rabbit comments 2025-11-20 23:14:46 +01:00
Johannes
88da043c00 remove plan file 2025-11-20 23:02:19 +01:00
Johannes
1cc3ceec55 clean up code 2025-11-20 23:00:07 +01:00
Johannes
50d15f6e07 draft 2025-11-20 13:50:17 +01:00
66 changed files with 2656 additions and 886 deletions

View File

@@ -51,6 +51,22 @@ export const POST = async (request: Request) => {
throw new ResourceNotFoundError("Organization", "Organization not found");
}
// Fetch survey for webhook payload
const survey = await getSurvey(surveyId);
if (!survey) {
logger.error({ url: request.url, surveyId }, `Survey with id ${surveyId} not found`);
return responses.notFoundResponse("Survey", surveyId, true);
}
if (survey.environmentId !== environmentId) {
logger.error(
{ url: request.url, surveyId, environmentId, surveyEnvironmentId: survey.environmentId },
`Survey ${surveyId} does not belong to environment ${environmentId}`
);
return responses.badRequestResponse("Survey not found in this environment");
}
// Fetch webhooks
const getWebhooksForPipeline = async (environmentId: string, event: PipelineTriggers, surveyId: string) => {
const webhooks = await prisma.webhook.findMany({
@@ -81,7 +97,16 @@ export const POST = async (request: Request) => {
body: JSON.stringify({
webhookId: webhook.id,
event,
data: response,
data: {
...response,
survey: {
title: survey.name,
type: survey.type,
status: survey.status,
createdAt: survey.createdAt,
updatedAt: survey.updatedAt,
},
},
}),
}).catch((error) => {
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
@@ -89,18 +114,12 @@ export const POST = async (request: Request) => {
);
if (event === "responseFinished") {
// Fetch integrations, survey, and responseCount in parallel
const [integrations, survey, responseCount] = await Promise.all([
// Fetch integrations and responseCount in parallel
const [integrations, responseCount] = await Promise.all([
getIntegrations(environmentId),
getSurvey(surveyId),
getResponseCountBySurveyId(surveyId),
]);
if (!survey) {
logger.error({ url: request.url, surveyId }, `Survey with id ${surveyId} not found`);
return new Response("Survey not found", { status: 404 });
}
if (integrations.length > 0) {
await handleIntegrations(integrations, inputValidation.data, survey);
}

View File

@@ -395,6 +395,7 @@ checksums:
common/updated: 8aa8ff2dc2977ca4b269e80a513100b4
common/updated_at: 8fdb85248e591254973403755dcc3724
common/upload: 4a6c84aa16db0f4e5697f49b45257bc7
common/upload_failed: d4dd7b6ee4c1572e4136659f74d9632b
common/upload_input_description: 64f59bc339568d52b8464b82546b70ea
common/url: ca97457614226960d41dd18c3c29c86b
common/user: 61073457a5c3901084b557d065f876be
@@ -1170,8 +1171,7 @@ checksums:
environments/surveys/edit/automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds: 1be3819ffa1db67385357ae933d69a7b
environments/surveys/edit/automatically_mark_the_survey_as_complete_after: c6ede2a5515a4ca72b36aec2583f43aa
environments/surveys/edit/back_button_label: 25af945e77336724b5276de291cc92d9
environments/surveys/edit/background_styling: 4e1e6fd2ec767bbff8767f6c0f68a731
environments/surveys/edit/block_deleted: c682259eb138ad84f8b4441abfd9b572
environments/surveys/edit/background_styling: eb4a06cf54a7271b493fab625d930570
environments/surveys/edit/block_duplicated: dc9e9fab2b1cd91f6c265324b34c6376
environments/surveys/edit/bold: 4d7306bc355ed2befd6a9237c5452ee6
environments/surveys/edit/brand_color: 84ddb5736deb9f5c081ffe4962a6c63e
@@ -1187,7 +1187,7 @@ checksums:
environments/surveys/edit/card_arrangement_for_survey_type_derived: c06b9aaebcc11bc16e57a445b62361fc
environments/surveys/edit/card_background_color: acd5d023e1d1a4471b053dce504c7a83
environments/surveys/edit/card_border_color: 8d7c7f4cbd99f154ce892dfa258eb504
environments/surveys/edit/card_styling: 01e88d58219539fb831e79f0bb3ce88e
environments/surveys/edit/card_styling: 47137a7e809b060ca94418202a8fd3c5
environments/surveys/edit/casual: 6534fe68718fade470a9031f7390409e
environments/surveys/edit/caution_edit_duplicate: ee93bccb34fcd707e1ef4735f1c2fc31
environments/surveys/edit/caution_edit_published_survey: faf7fc57c776f2a9104d143e20044486
@@ -1243,6 +1243,7 @@ checksums:
environments/surveys/edit/css_selector: 615e9f1b74622df29de28a5b5614c6fe
environments/surveys/edit/cta_button_label: ec070ffba38eae24751bb3a4c1e14c81
environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429
environments/surveys/edit/customize_survey_logo: 7f7e26026c88a727228f2d7a00d914e2
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
environments/surveys/edit/date_format: e95dfc41ac944874868487457ddc057a
environments/surveys/edit/days_before_showing_this_survey_again: 354fb28c5ff076f022d82a20c749ee46
@@ -1343,9 +1344,9 @@ checksums:
environments/surveys/edit/hide_back_button_description: caaa30cf43c5611577933a1c9f44b9ee
environments/surveys/edit/hide_block_settings: c24c3d3892c251792e297cdc036d2fde
environments/surveys/edit/hide_logo: eef4de2e3fffe8cbe32bff4f6f7250d8
environments/surveys/edit/hide_logo_from_survey: 9d44321539cc2b397376a35bb8b3d1cd
environments/surveys/edit/hide_progress_bar: 7eefe7db6a051105bded521d94204933
environments/surveys/edit/hide_question_settings: 99127cd016db2f7fc80333b36473c0ef
environments/surveys/edit/hide_the_logo_in_this_specific_survey: 29d4c6c714886e57bc29ad292d0f5a00
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
environments/surveys/edit/how_funky_do_you_want_your_cards_in_survey_type_derived_surveys: 3cb16b37510c01af20a80f51b598346e
environments/surveys/edit/if_you_need_more_please: a7d208c283caf6b93800b809fca80768
@@ -1392,6 +1393,7 @@ checksums:
environments/surveys/edit/load_segment: 5341d3de37ff10f7526152e38e25e3c5
environments/surveys/edit/logic_error_warning: 542fbb918ffdb29e6f9a4a6196ffb558
environments/surveys/edit/logic_error_warning_text: f2afad8852a95ed169a39959efbf592c
environments/surveys/edit/logo_settings: 9f54ca6684e989cc38bf177425b6d366
environments/surveys/edit/long_answer: 3a97f8d2e90aba6e679917a0c5670c53
environments/surveys/edit/long_answer_toggle_description: 86bcdfeb74d9825c2f2d5a215e92d111
environments/surveys/edit/lower_label: 45985bca022d4370bd6e013af75d5160
@@ -1424,6 +1426,7 @@ checksums:
environments/surveys/edit/overwrite_global_waiting_time: 7bc23bd502b6bd048356b67acd956d9d
environments/surveys/edit/overwrite_global_waiting_time_description: 795cf6e93d4c01d2e43aa0ebab601c6e
environments/surveys/edit/overwrite_placement: d7278be243e52c5091974e0fc4a7c342
environments/surveys/edit/overwrite_survey_logo: a89cb566dfcc1559446abd8b830c84ed
environments/surveys/edit/overwrite_the_global_placement_of_the_survey: 874075712254b1ce92e099d89f675a48
environments/surveys/edit/pick_a_background_from_our_library_or_upload_your_own: b83bcbdc8131fc9524d272ff5dede754
environments/surveys/edit/picture_idx: 55e053ad1ade5d17c582406706036028

View File

@@ -422,6 +422,7 @@
"updated": "Aktualisiert",
"updated_at": "Aktualisiert am",
"upload": "Hochladen",
"upload_failed": "Upload fehlgeschlagen. Bitte versuche es erneut.",
"upload_input_description": "Klicke oder ziehe, um Dateien hochzuladen.",
"url": "URL",
"user": "Benutzer",
@@ -1255,7 +1256,7 @@
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Schließe die Umfrage automatisch, wenn der Benutzer nach einer bestimmten Anzahl von Sekunden nicht antwortet.",
"automatically_mark_the_survey_as_complete_after": "Umfrage automatisch als abgeschlossen markieren nach",
"back_button_label": "Zurück\"- Button ",
"background_styling": "Hintergründe",
"background_styling": "Hintergrundgestaltung",
"block_duplicated": "Block dupliziert.",
"bold": "Fett",
"brand_color": "Markenfarbe",
@@ -1271,7 +1272,7 @@
"card_arrangement_for_survey_type_derived": "Kartenanordnung für {surveyTypeDerived} Umfragen",
"card_background_color": "Hintergrundfarbe der Karte",
"card_border_color": "Farbe des Kartenrandes",
"card_styling": "Kartenstil",
"card_styling": "Kartengestaltung",
"casual": "Lässig",
"caution_edit_duplicate": "Duplizieren & bearbeiten",
"caution_edit_published_survey": "Eine veröffentlichte Umfrage bearbeiten?",
@@ -1327,6 +1328,7 @@
"css_selector": "CSS-Selektor",
"cta_button_label": "\"CTA\"-Schaltflächen-Beschriftung",
"custom_hostname": "Benutzerdefinierter Hostname",
"customize_survey_logo": "Umfragelogo anpassen",
"darken_or_lighten_background_of_your_choice": "Hintergrund deiner Wahl abdunkeln oder aufhellen.",
"date_format": "Datumsformat",
"days_before_showing_this_survey_again": "Tage nachdem eine beliebige Umfrage angezeigt wurde, bevor diese Umfrage erscheinen kann.",
@@ -1427,9 +1429,9 @@
"hide_back_button_description": "Den Zurück-Button in der Umfrage nicht anzeigen",
"hide_block_settings": "Block-Einstellungen ausblenden",
"hide_logo": "Logo verstecken",
"hide_logo_from_survey": "Logo in dieser Umfrage ausblenden",
"hide_progress_bar": "Fortschrittsbalken ausblenden",
"hide_question_settings": "Frageeinstellungen ausblenden",
"hide_the_logo_in_this_specific_survey": "Logo in dieser speziellen Umfrage verstecken",
"hostname": "Hostname",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Wie funky sollen deine Karten in {surveyTypeDerived} Umfragen sein",
"if_you_need_more_please": "Wenn Du mehr brauchst, bitte",
@@ -1476,6 +1478,7 @@
"load_segment": "Segment laden",
"logic_error_warning": "Änderungen werden zu Logikfehlern führen",
"logic_error_warning_text": "Das Ändern des Fragetypen entfernt die Logikbedingungen von dieser Frage",
"logo_settings": "Logo-Einstellungen",
"long_answer": "Lange Antwort",
"long_answer_toggle_description": "Ermöglichen Sie den Befragten, längere Antworten über mehrere Zeilen zu schreiben.",
"lower_label": "Unteres Label",
@@ -1508,6 +1511,7 @@
"overwrite_global_waiting_time": "Benutzerdefinierte Wartezeit festlegen",
"overwrite_global_waiting_time_description": "Die Projektkonfiguration nur für diese Umfrage überschreiben.",
"overwrite_placement": "Platzierung überschreiben",
"overwrite_survey_logo": "Benutzerdefiniertes Umfragelogo festlegen",
"overwrite_the_global_placement_of_the_survey": "Platzierung für diese Umfrage überschreiben",
"pick_a_background_from_our_library_or_upload_your_own": "Wähle einen Hintergrund aus oder lade deinen eigenen hoch.",
"picture_idx": "Bild {idx}",

View File

@@ -422,6 +422,7 @@
"updated": "Updated",
"updated_at": "Updated at",
"upload": "Upload",
"upload_failed": "Upload failed. Please try again.",
"upload_input_description": "Click or drag to upload files.",
"url": "URL",
"user": "User",
@@ -1255,7 +1256,7 @@
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Automatically close the survey if the user does not respond after certain number of seconds.",
"automatically_mark_the_survey_as_complete_after": "Automatically mark the survey as complete after",
"back_button_label": "\"Back\" Button Label",
"background_styling": "Background Styling",
"background_styling": "Background styling",
"block_duplicated": "Block duplicated.",
"bold": "Bold",
"brand_color": "Brand color",
@@ -1271,7 +1272,7 @@
"card_arrangement_for_survey_type_derived": "Card Arrangement for {surveyTypeDerived} Surveys",
"card_background_color": "Card background color",
"card_border_color": "Card border color",
"card_styling": "Card Styling",
"card_styling": "Card styling",
"casual": "Casual",
"caution_edit_duplicate": "Duplicate & edit",
"caution_edit_published_survey": "Edit a published survey?",
@@ -1327,6 +1328,7 @@
"css_selector": "CSS Selector",
"cta_button_label": "\"CTA\" button label",
"custom_hostname": "Custom hostname",
"customize_survey_logo": "Customize the survey logo",
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
"date_format": "Date format",
"days_before_showing_this_survey_again": "days after any survey is shown before this survey can appear.",
@@ -1363,6 +1365,10 @@
"error_saving_changes": "Error saving changes",
"even_after_they_submitted_a_response_e_g_feedback_box": "Allow multiple responses; continue showing even after a response (e.g., Feedback Box).",
"everyone": "Everyone",
"export_survey": "Export survey",
"export_survey_error": "Failed to export survey",
"export_survey_loading": "Exporting survey...",
"export_survey_success": "Survey exported successfully",
"external_urls_paywall_tooltip": "Please upgrade to Startup plan to customize external URLs. This helps us prevent phishing.",
"fallback_missing": "Fallback missing",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
@@ -1427,9 +1433,9 @@
"hide_back_button_description": "Do not display the back button in the survey",
"hide_block_settings": "Hide Block settings",
"hide_logo": "Hide logo",
"hide_logo_from_survey": "Hide logo from this survey",
"hide_progress_bar": "Hide progress bar",
"hide_question_settings": "Hide Question settings",
"hide_the_logo_in_this_specific_survey": "Hide the logo in this specific survey",
"hostname": "Hostname",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "How funky do you want your cards in {surveyTypeDerived} Surveys",
"if_you_need_more_please": "If you need more, please",
@@ -1437,6 +1443,28 @@
"ignore_global_waiting_time": "Ignore project-wide waiting time",
"ignore_global_waiting_time_description": "This survey can show whenever its conditions are met, even if another survey was shown recently.",
"image": "Image",
"import_error_invalid_json": "Invalid JSON file",
"import_error_validation": "Survey validation failed",
"import_info_quotas": "Due to the complexity of quotas, they are not being imported. Please create them manually after import.",
"import_info_triggers": "Triggers will be automatically matched or created in your environment.",
"import_survey": "Import Survey",
"import_survey_description": "Import a survey from a JSON file",
"import_survey_error": "Failed to import survey",
"import_survey_errors": "Errors",
"import_survey_file_label": "Select JSON file",
"import_survey_import": "Import Survey",
"import_survey_name_label": "Survey Name",
"import_survey_new_id": "New Survey ID",
"import_survey_success": "Survey imported successfully",
"import_survey_upload": "Upload File",
"import_survey_validate": "Validating...",
"import_survey_warnings": "Warnings",
"import_warning_action_classes": "Action classes will be matched or created in the target environment.",
"import_warning_follow_ups": "Survey follow-ups require an enterprise plan and might be removed.",
"import_warning_images": "Images detected in survey. You'll need to re-upload images after import.",
"import_warning_multi_language": "Multi-language surveys require an enterprise plan and might be removed.",
"import_warning_recaptcha": "Spam protection requires an enterprise plan and might be disabled.",
"import_warning_segments": "Segment targeting cannot be imported. Configure targeting after import.",
"includes_all_of": "Includes all of",
"includes_one_of": "Includes one of",
"initial_value": "Initial value",
@@ -1476,6 +1504,7 @@
"load_segment": "Load segment",
"logic_error_warning": "Changing will cause logic errors",
"logic_error_warning_text": "Changing the question type will remove the logic conditions from this question",
"logo_settings": "Logo settings",
"long_answer": "Long answer",
"long_answer_toggle_description": "Allow respondents to write longer, multi-line answers.",
"lower_label": "Lower Label",
@@ -1508,6 +1537,7 @@
"overwrite_global_waiting_time": "Set custom waiting time",
"overwrite_global_waiting_time_description": "Override the project configuration for this survey only.",
"overwrite_placement": "Overwrite placement",
"overwrite_survey_logo": "Set custom survey logo",
"overwrite_the_global_placement_of_the_survey": "Overwrite the global placement of the survey",
"pick_a_background_from_our_library_or_upload_your_own": "Pick a background from our library or upload your own.",
"picture_idx": "Picture {idx}",
@@ -1684,11 +1714,37 @@
"zip": "Zip"
},
"error_deleting_survey": "An error occured while deleting survey",
"export_survey": "Export survey",
"export_survey_error": "Failed to export survey",
"export_survey_loading": "Exporting survey...",
"export_survey_success": "Survey exported successfully",
"filter": {
"complete_and_partial_responses": "Complete and partial responses",
"complete_responses": "Complete responses",
"partial_responses": "Partial responses"
},
"import_error_invalid_json": "Invalid JSON file",
"import_error_validation": "Survey validation failed",
"import_info_quotas": "Due to the complexity of quotas, they are not being imported. Please create them manually after import.",
"import_info_triggers": "Triggers will be automatically matched or created in your environment.",
"import_survey": "Import Survey",
"import_survey_description": "Import a survey from a JSON file",
"import_survey_error": "Failed to import survey",
"import_survey_errors": "Errors",
"import_survey_file_label": "Select JSON file",
"import_survey_import": "Import Survey",
"import_survey_name_label": "Survey Name",
"import_survey_new_id": "New Survey ID",
"import_survey_success": "Survey imported successfully",
"import_survey_upload": "Upload File",
"import_survey_validate": "Validating...",
"import_survey_warnings": "Warnings",
"import_warning_action_classes": "Action classes will be matched or created in the target environment.",
"import_warning_follow_ups": "Survey follow-ups require an enterprise plan. Follow-ups will be removed.",
"import_warning_images": "Images detected in survey. You'll need to re-upload images after import.",
"import_warning_multi_language": "Multi-language surveys require an enterprise plan. Languages will be removed.",
"import_warning_recaptcha": "Spam protection requires an enterprise plan. reCAPTCHA will be disabled.",
"import_warning_segments": "Segment targeting cannot be imported. Configure targeting after import.",
"new_survey": "New Survey",
"no_surveys_created_yet": "No surveys created yet",
"open_options": "Open options",

View File

@@ -422,6 +422,7 @@
"updated": "Actualizado",
"updated_at": "Actualizado el",
"upload": "Subir",
"upload_failed": "La subida ha fallado. Por favor, inténtalo de nuevo.",
"upload_input_description": "Haz clic o arrastra para subir archivos.",
"url": "URL",
"user": "Usuario",
@@ -1255,7 +1256,7 @@
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Cerrar automáticamente la encuesta si el usuario no responde después de cierto número de segundos.",
"automatically_mark_the_survey_as_complete_after": "Marcar automáticamente la encuesta como completa después de",
"back_button_label": "Etiqueta del botón \"Atrás\"",
"background_styling": "Estilo de fondo",
"background_styling": "Estilo del fondo",
"block_duplicated": "Bloque duplicado.",
"bold": "Negrita",
"brand_color": "Color de marca",
@@ -1271,7 +1272,7 @@
"card_arrangement_for_survey_type_derived": "Disposición de tarjetas para encuestas de tipo {surveyTypeDerived}",
"card_background_color": "Color de fondo de la tarjeta",
"card_border_color": "Color del borde de la tarjeta",
"card_styling": "Estilo de tarjeta",
"card_styling": "Estilo de la tarjeta",
"casual": "Informal",
"caution_edit_duplicate": "Duplicar y editar",
"caution_edit_published_survey": "¿Editar una encuesta publicada?",
@@ -1327,6 +1328,7 @@
"css_selector": "Selector CSS",
"cta_button_label": "Etiqueta del botón \"CTA\"",
"custom_hostname": "Nombre de host personalizado",
"customize_survey_logo": "Personalizar el logotipo de la encuesta",
"darken_or_lighten_background_of_your_choice": "Oscurece o aclara el fondo de tu elección.",
"date_format": "Formato de fecha",
"days_before_showing_this_survey_again": "días después de que se muestre cualquier encuesta antes de que esta encuesta pueda aparecer.",
@@ -1427,9 +1429,9 @@
"hide_back_button_description": "No mostrar el botón de retroceso en la encuesta",
"hide_block_settings": "Ocultar ajustes del bloque",
"hide_logo": "Ocultar logotipo",
"hide_logo_from_survey": "Ocultar logotipo de esta encuesta",
"hide_progress_bar": "Ocultar barra de progreso",
"hide_question_settings": "Ocultar ajustes de la pregunta",
"hide_the_logo_in_this_specific_survey": "Ocultar el logotipo en esta encuesta específica",
"hostname": "Nombre de host",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "¿Cuánto estilo quieres darle a tus tarjetas en las encuestas de tipo {surveyTypeDerived}?",
"if_you_need_more_please": "Si necesitas más, por favor",
@@ -1476,6 +1478,7 @@
"load_segment": "Cargar segmento",
"logic_error_warning": "El cambio causará errores lógicos",
"logic_error_warning_text": "Cambiar el tipo de pregunta eliminará las condiciones lógicas de esta pregunta",
"logo_settings": "Ajustes del logotipo",
"long_answer": "Respuesta larga",
"long_answer_toggle_description": "Permitir a los encuestados escribir respuestas más largas y de varias líneas.",
"lower_label": "Etiqueta inferior",
@@ -1508,6 +1511,7 @@
"overwrite_global_waiting_time": "Establecer tiempo de espera personalizado",
"overwrite_global_waiting_time_description": "Anular la configuración del proyecto solo para esta encuesta.",
"overwrite_placement": "Sobrescribir ubicación",
"overwrite_survey_logo": "Establecer logotipo personalizado para la encuesta",
"overwrite_the_global_placement_of_the_survey": "Sobrescribir la ubicación global de la encuesta",
"pick_a_background_from_our_library_or_upload_your_own": "Elige un fondo de nuestra biblioteca o sube el tuyo propio.",
"picture_idx": "Imagen {idx}",

View File

@@ -422,6 +422,7 @@
"updated": "Mise à jour",
"updated_at": "Mis à jour à",
"upload": "Télécharger",
"upload_failed": "Échec du téléchargement. Veuillez réessayer.",
"upload_input_description": "Cliquez ou faites glisser pour charger un fichier.",
"url": "URL",
"user": "Utilisateur",
@@ -1255,7 +1256,7 @@
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fermer automatiquement l'enquête si l'utilisateur ne répond pas après un certain nombre de secondes.",
"automatically_mark_the_survey_as_complete_after": "Marquer automatiquement l'enquête comme terminée après",
"back_button_label": "Label du bouton \"Retour''",
"background_styling": "Style de fond",
"background_styling": "Style d'arrière-plan",
"block_duplicated": "Bloc dupliqué.",
"bold": "Gras",
"brand_color": "Couleur de marque",
@@ -1327,6 +1328,7 @@
"css_selector": "Sélecteur CSS",
"cta_button_label": "Libellé du bouton «CTA»",
"custom_hostname": "Nom d'hôte personnalisé",
"customize_survey_logo": "Personnaliser le logo de l'enquête",
"darken_or_lighten_background_of_your_choice": "Assombrir ou éclaircir l'arrière-plan de votre choix.",
"date_format": "Format de date",
"days_before_showing_this_survey_again": "jours après qu'une enquête soit affichée avant que cette enquête puisse apparaître.",
@@ -1427,9 +1429,9 @@
"hide_back_button_description": "Ne pas afficher le bouton retour dans l'enquête",
"hide_block_settings": "Masquer les paramètres du bloc",
"hide_logo": "Cacher le logo",
"hide_logo_from_survey": "Masquer le logo de cette enquête",
"hide_progress_bar": "Cacher la barre de progression",
"hide_question_settings": "Masquer les paramètres de la question",
"hide_the_logo_in_this_specific_survey": "Cacher le logo dans cette enquête spécifique",
"hostname": "Nom d'hôte",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "À quel point voulez-vous que vos cartes soient funky dans les enquêtes {surveyTypeDerived}",
"if_you_need_more_please": "Si vous en avez besoin de plus, s'il vous plaît",
@@ -1476,6 +1478,7 @@
"load_segment": "Segment de chargement",
"logic_error_warning": "Changer causera des erreurs logiques",
"logic_error_warning_text": "Changer le type de question supprimera les conditions logiques de cette question.",
"logo_settings": "Paramètres du logo",
"long_answer": "Longue réponse",
"long_answer_toggle_description": "Permettre aux répondants d'écrire des réponses plus longues et sur plusieurs lignes.",
"lower_label": "Étiquette inférieure",
@@ -1508,6 +1511,7 @@
"overwrite_global_waiting_time": "Définir un temps d'attente personnalisé",
"overwrite_global_waiting_time_description": "Remplacer la configuration du projet pour cette enquête uniquement.",
"overwrite_placement": "Surcharge de placement",
"overwrite_survey_logo": "Définir un logo d'enquête personnalisé",
"overwrite_the_global_placement_of_the_survey": "Surcharger le placement global de l'enquête",
"pick_a_background_from_our_library_or_upload_your_own": "Choisissez un arrière-plan dans notre bibliothèque ou téléchargez le vôtre.",
"picture_idx": "Image {idx}",

View File

@@ -422,6 +422,7 @@
"updated": "更新済み",
"updated_at": "更新日時",
"upload": "アップロード",
"upload_failed": "アップロードに失敗しました。もう一度お試しください。",
"upload_input_description": "クリックまたはドラッグしてファイルをアップロードしてください。",
"url": "URL",
"user": "ユーザー",
@@ -1255,7 +1256,7 @@
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "ユーザーが一定秒数応答しない場合、フォームを自動的に閉じます。",
"automatically_mark_the_survey_as_complete_after": "フォームを自動的に完了としてマークする",
"back_button_label": "「戻る」ボタンのラベル",
"background_styling": "背景のスタイル",
"background_styling": "背景のスタイル設定",
"block_duplicated": "ブロックが複製されました。",
"bold": "太字",
"brand_color": "ブランドカラー",
@@ -1271,7 +1272,7 @@
"card_arrangement_for_survey_type_derived": "{surveyTypeDerived} フォームのカード配置",
"card_background_color": "カードの背景色",
"card_border_color": "カードの枠線の色",
"card_styling": "カードのスタイル",
"card_styling": "カードのスタイル設定",
"casual": "カジュアル",
"caution_edit_duplicate": "複製して編集",
"caution_edit_published_survey": "公開済みのフォームを編集しますか?",
@@ -1327,6 +1328,7 @@
"css_selector": "CSSセレクター",
"cta_button_label": "\"CTA\"ボタンのラベル",
"custom_hostname": "カスタムホスト名",
"customize_survey_logo": "アンケートのロゴをカスタマイズする",
"darken_or_lighten_background_of_your_choice": "お好みの背景を暗くしたり明るくしたりします。",
"date_format": "日付形式",
"days_before_showing_this_survey_again": "任意のフォームが表示された後、このフォームが再表示されるまでの日数。",
@@ -1427,9 +1429,9 @@
"hide_back_button_description": "フォームに「戻る」ボタンを表示しない",
"hide_block_settings": "ブロック設定を非表示",
"hide_logo": "ロゴを非表示",
"hide_logo_from_survey": "このアンケートからロゴを非表示にする",
"hide_progress_bar": "プログレスバーを非表示",
"hide_question_settings": "質問設定を非表示",
"hide_the_logo_in_this_specific_survey": "この特定のフォームでロゴを非表示にする",
"hostname": "ホスト名",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "{surveyTypeDerived} フォームのカードをどれくらいユニークにしますか",
"if_you_need_more_please": "さらに必要な場合は、",
@@ -1476,6 +1478,7 @@
"load_segment": "セグメントを読み込み",
"logic_error_warning": "変更するとロジックエラーが発生します",
"logic_error_warning_text": "質問の種類を変更すると、この質問のロジック条件が削除されます",
"logo_settings": "ロゴ設定",
"long_answer": "長文回答",
"long_answer_toggle_description": "回答者が長文の複数行の回答を書けるようにします。",
"lower_label": "下限ラベル",
@@ -1508,6 +1511,7 @@
"overwrite_global_waiting_time": "カスタム待機時間を設定する",
"overwrite_global_waiting_time_description": "このフォームのみプロジェクト設定を上書きします。",
"overwrite_placement": "配置を上書き",
"overwrite_survey_logo": "カスタムアンケートロゴを設定する",
"overwrite_the_global_placement_of_the_survey": "フォームのグローバルな配置を上書き",
"pick_a_background_from_our_library_or_upload_your_own": "ライブラリから背景を選択するか、独自にアップロードしてください。",
"picture_idx": "写真 {idx}",

View File

@@ -422,6 +422,7 @@
"updated": "Bijgewerkt",
"updated_at": "Bijgewerkt op",
"upload": "Uploaden",
"upload_failed": "Upload mislukt. Probeer het opnieuw.",
"upload_input_description": "Klik of sleep om bestanden te uploaden.",
"url": "URL",
"user": "Gebruiker",
@@ -1255,7 +1256,7 @@
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Sluit de enquête automatisch af als de gebruiker na een bepaald aantal seconden niet reageert.",
"automatically_mark_the_survey_as_complete_after": "Markeer de enquête daarna automatisch als voltooid",
"back_button_label": "Knoplabel 'Terug'",
"background_styling": "Achtergrondstyling",
"background_styling": "Achtergrondstijl",
"block_duplicated": "Blok gedupliceerd.",
"bold": "Vetgedrukt",
"brand_color": "Merk kleur",
@@ -1271,7 +1272,7 @@
"card_arrangement_for_survey_type_derived": "Kaartarrangement voor {surveyTypeDerived} enquêtes",
"card_background_color": "Achtergrondkleur van de kaart",
"card_border_color": "Randkleur kaart",
"card_styling": "Kaartstyling",
"card_styling": "Kaartstijl",
"casual": "Casual",
"caution_edit_duplicate": "Dupliceren en bewerken",
"caution_edit_published_survey": "Een gepubliceerde enquête bewerken?",
@@ -1327,6 +1328,7 @@
"css_selector": "CSS-kiezer",
"cta_button_label": "\"CTA\" knoplabel",
"custom_hostname": "Aangepaste hostnaam",
"customize_survey_logo": "Pas het enquêtelogo aan",
"darken_or_lighten_background_of_your_choice": "Maak de achtergrond naar keuze donkerder of lichter.",
"date_format": "Datumformaat",
"days_before_showing_this_survey_again": "dagen nadat een enquête is getoond voordat deze enquête kan verschijnen.",
@@ -1427,9 +1429,9 @@
"hide_back_button_description": "Geef de terugknop niet weer in de enquête",
"hide_block_settings": "Blokinstellingen verbergen",
"hide_logo": "Logo verbergen",
"hide_logo_from_survey": "Verberg logo van deze enquête",
"hide_progress_bar": "Voortgangsbalk verbergen",
"hide_question_settings": "Vraaginstellingen verbergen",
"hide_the_logo_in_this_specific_survey": "Verberg het logo in deze specifieke enquête",
"hostname": "Hostnaam",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Hoe funky wil je je kaarten hebben in {surveyTypeDerived} Enquêtes",
"if_you_need_more_please": "Als u meer nodig heeft, alstublieft",
@@ -1476,6 +1478,7 @@
"load_segment": "Laadsegment",
"logic_error_warning": "Wijzigen zal logische fouten veroorzaken",
"logic_error_warning_text": "Als u het vraagtype wijzigt, worden de logische voorwaarden van deze vraag verwijderd",
"logo_settings": "Logo-instellingen",
"long_answer": "Lang antwoord",
"long_answer_toggle_description": "Sta respondenten toe om langere antwoorden met meerdere regels te schrijven.",
"lower_label": "Lager etiket",
@@ -1508,6 +1511,7 @@
"overwrite_global_waiting_time": "Stel aangepaste wachttijd in",
"overwrite_global_waiting_time_description": "Overschrijf de projectconfiguratie alleen voor deze enquête.",
"overwrite_placement": "Plaatsing overschrijven",
"overwrite_survey_logo": "Stel aangepast enquêtelogo in",
"overwrite_the_global_placement_of_the_survey": "Overschrijf de globale plaatsing van de enquête",
"pick_a_background_from_our_library_or_upload_your_own": "Kies een achtergrond uit onze bibliotheek of upload je eigen achtergrond.",
"picture_idx": "Afbeelding {idx}",

View File

@@ -422,6 +422,7 @@
"updated": "atualizado",
"updated_at": "Atualizado em",
"upload": "Enviar",
"upload_failed": "Falha no upload. Tente novamente.",
"upload_input_description": "Clique ou arraste para fazer o upload de arquivos.",
"url": "URL",
"user": "Usuário",
@@ -1255,7 +1256,7 @@
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Feche automaticamente a pesquisa se o usuário não responder depois de alguns segundos.",
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente a pesquisa como concluída após",
"back_button_label": "Voltar",
"background_styling": "Estilo de Fundo",
"background_styling": "Estilo do plano de fundo",
"block_duplicated": "Bloco duplicado.",
"bold": "Negrito",
"brand_color": "Cor da marca",
@@ -1271,7 +1272,7 @@
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Pesquisas {surveyTypeDerived}",
"card_background_color": "Cor de fundo do cartão",
"card_border_color": "Cor da borda do cartão",
"card_styling": "Estilização de Cartão",
"card_styling": "Estilo do cartão",
"casual": "Casual",
"caution_edit_duplicate": "Duplicar e editar",
"caution_edit_published_survey": "Editar uma pesquisa publicada?",
@@ -1327,6 +1328,7 @@
"css_selector": "Seletor CSS",
"cta_button_label": "Rótulo do botão \"CTA\"",
"custom_hostname": "Hostname personalizado",
"customize_survey_logo": "Personalizar o logo da pesquisa",
"darken_or_lighten_background_of_your_choice": "Escureça ou clareie o fundo da sua escolha.",
"date_format": "Formato de data",
"days_before_showing_this_survey_again": "dias após qualquer pesquisa ser mostrada antes que esta pesquisa possa aparecer.",
@@ -1427,9 +1429,9 @@
"hide_back_button_description": "Não exibir o botão de voltar na pesquisa",
"hide_block_settings": "Ocultar configurações do bloco",
"hide_logo": "Esconder logo",
"hide_logo_from_survey": "Esconder logo desta pesquisa",
"hide_progress_bar": "Esconder barra de progresso",
"hide_question_settings": "Ocultar configurações da pergunta",
"hide_the_logo_in_this_specific_survey": "Esconder o logo nessa pesquisa específica",
"hostname": "nome do host",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão descoladas você quer suas cartas em Pesquisas {surveyTypeDerived}",
"if_you_need_more_please": "Se você precisar de mais, por favor",
@@ -1476,6 +1478,7 @@
"load_segment": "segmento de carga",
"logic_error_warning": "Mudar vai causar erros de lógica",
"logic_error_warning_text": "Mudar o tipo de pergunta vai remover as condições lógicas dessa pergunta",
"logo_settings": "Configurações do logo",
"long_answer": "resposta longa",
"long_answer_toggle_description": "Permitir que os respondentes escrevam respostas mais longas e com várias linhas.",
"lower_label": "Etiqueta Inferior",
@@ -1508,6 +1511,7 @@
"overwrite_global_waiting_time": "Definir tempo de espera personalizado",
"overwrite_global_waiting_time_description": "Substituir a configuração do projeto apenas para esta pesquisa.",
"overwrite_placement": "Substituir posicionamento",
"overwrite_survey_logo": "Definir logo personalizado para a pesquisa",
"overwrite_the_global_placement_of_the_survey": "Substituir a posição global da pesquisa",
"pick_a_background_from_our_library_or_upload_your_own": "Escolha um fundo da nossa biblioteca ou faça upload do seu próprio.",
"picture_idx": "Imagem {idx}",

View File

@@ -422,6 +422,7 @@
"updated": "Atualizado",
"updated_at": "Atualizado em",
"upload": "Carregar",
"upload_failed": "Falha no carregamento. Por favor, tente novamente.",
"upload_input_description": "Clique ou arraste para carregar ficheiros.",
"url": "URL",
"user": "Utilizador",
@@ -1255,7 +1256,7 @@
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fechar automaticamente o inquérito se o utilizador não responder após um certo número de segundos.",
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente o inquérito como concluído após",
"back_button_label": "Rótulo do botão \"Voltar\"",
"background_styling": "Estilo de Fundo",
"background_styling": "Estilo de fundo",
"block_duplicated": "Bloco duplicado.",
"bold": "Negrito",
"brand_color": "Cor da marca",
@@ -1271,7 +1272,7 @@
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Inquéritos {surveyTypeDerived}",
"card_background_color": "Cor de fundo do cartão",
"card_border_color": "Cor da borda do cartão",
"card_styling": "Estilo do cartão",
"card_styling": "Estilo de cartão",
"casual": "Casual",
"caution_edit_duplicate": "Duplicar e editar",
"caution_edit_published_survey": "Editar um inquérito publicado?",
@@ -1327,6 +1328,7 @@
"css_selector": "Seletor CSS",
"cta_button_label": "Etiqueta do botão \"CTA\"",
"custom_hostname": "Nome do host personalizado",
"customize_survey_logo": "Personalizar o logótipo do inquérito",
"darken_or_lighten_background_of_your_choice": "Escurecer ou clarear o fundo da sua escolha.",
"date_format": "Formato da data",
"days_before_showing_this_survey_again": "dias após qualquer inquérito ser mostrado antes que este inquérito possa aparecer.",
@@ -1427,9 +1429,9 @@
"hide_back_button_description": "Não mostrar o botão de retroceder no inquérito",
"hide_block_settings": "Ocultar definições do bloco",
"hide_logo": "Esconder logótipo",
"hide_logo_from_survey": "Ocultar logótipo deste inquérito",
"hide_progress_bar": "Ocultar barra de progresso",
"hide_question_settings": "Ocultar definições da pergunta",
"hide_the_logo_in_this_specific_survey": "Ocultar o logótipo neste inquérito específico",
"hostname": "Nome do host",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão extravagantes quer os seus cartões em Inquéritos {surveyTypeDerived}",
"if_you_need_more_please": "Se precisar de mais, por favor",
@@ -1476,6 +1478,7 @@
"load_segment": "Carregar segmento",
"logic_error_warning": "A alteração causará erros de lógica",
"logic_error_warning_text": "Alterar o tipo de pergunta irá remover as condições lógicas desta pergunta",
"logo_settings": "Definições do logótipo",
"long_answer": "Resposta longa",
"long_answer_toggle_description": "Permitir que os inquiridos escrevam respostas mais longas e com várias linhas.",
"lower_label": "Etiqueta Inferior",
@@ -1508,6 +1511,7 @@
"overwrite_global_waiting_time": "Definir tempo de espera personalizado",
"overwrite_global_waiting_time_description": "Substituir a configuração do projeto apenas para este inquérito.",
"overwrite_placement": "Substituir colocação",
"overwrite_survey_logo": "Definir logótipo de inquérito personalizado",
"overwrite_the_global_placement_of_the_survey": "Substituir a colocação global do inquérito",
"pick_a_background_from_our_library_or_upload_your_own": "Escolha um fundo da nossa biblioteca ou carregue o seu próprio.",
"picture_idx": "Imagem {idx}",

View File

@@ -422,6 +422,7 @@
"updated": "Actualizat",
"updated_at": "Actualizat la",
"upload": "Încărcați",
"upload_failed": "Încărcarea a eșuat. Vă rugăm să încercați din nou.",
"upload_input_description": "Faceți clic sau trageți pentru a încărca fișiere.",
"url": "URL",
"user": "Utilizator",
@@ -1327,6 +1328,7 @@
"css_selector": "Selector CSS",
"cta_button_label": "Eticheta butonului \"CTA\"",
"custom_hostname": "Gazdă personalizată",
"customize_survey_logo": "Personalizează logo-ul chestionarului",
"darken_or_lighten_background_of_your_choice": "Întunecați sau luminați fundalul după preferințe.",
"date_format": "Format dată",
"days_before_showing_this_survey_again": "zile după afișarea oricărui sondaj înainte ca acest sondaj să poată apărea din nou.",
@@ -1427,9 +1429,9 @@
"hide_back_button_description": "Nu afișa butonul Înapoi în sondaj",
"hide_block_settings": "Ascunde setările blocului",
"hide_logo": "Ascunde logo",
"hide_logo_from_survey": "Ascunde logo-ul din acest chestionar",
"hide_progress_bar": "Ascunde bara de progres",
"hide_question_settings": "Ascunde setările întrebării",
"hide_the_logo_in_this_specific_survey": "Ascunde logo-ul în acest chestionar specific",
"hostname": "Nume gazdă",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Cât de funky doriți să fie cardurile dumneavoastră în sondajele de tip {surveyTypeDerived}",
"if_you_need_more_please": "Dacă aveți nevoie de mai multe, vă rugăm să",
@@ -1476,6 +1478,7 @@
"load_segment": "Încarcă segment",
"logic_error_warning": "Schimbarea va provoca erori de logică",
"logic_error_warning_text": "Schimbarea tipului de întrebare va elimina condițiile de logică din această întrebare",
"logo_settings": "Setări logo",
"long_answer": "Răspuns lung",
"long_answer_toggle_description": "Permite respondenților să scrie răspunsuri mai lungi, pe mai multe rânduri.",
"lower_label": "Etichetă inferioară",
@@ -1508,6 +1511,7 @@
"overwrite_global_waiting_time": "Setează un timp de așteptare personalizat",
"overwrite_global_waiting_time_description": "Suprascrie configurația proiectului doar pentru acest sondaj.",
"overwrite_placement": "Suprascriere amplasare",
"overwrite_survey_logo": "Setează un logo personalizat pentru chestionar",
"overwrite_the_global_placement_of_the_survey": "Suprascrie amplasarea globală a sondajului",
"pick_a_background_from_our_library_or_upload_your_own": "Alege un fundal din biblioteca noastră sau încarcă unul propriu.",
"picture_idx": "Poză {idx}",

View File

@@ -422,6 +422,7 @@
"updated": "已更新",
"updated_at": "更新 于",
"upload": "上传",
"upload_failed": "上传失败,请重试。",
"upload_input_description": "点击 或 拖动 上传 文件",
"url": "URL",
"user": "用户",
@@ -1255,7 +1256,7 @@
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "用户未在一定秒数内应答时 自动关闭 问卷",
"automatically_mark_the_survey_as_complete_after": "自动 标记 调查 为 完成 在",
"back_button_label": "\"返回\" 按钮标签",
"background_styling": "背景 样式",
"background_styling": "背景样式",
"block_duplicated": "区块已复制。",
"bold": "粗体",
"brand_color": "品牌 颜色",
@@ -1271,7 +1272,7 @@
"card_arrangement_for_survey_type_derived": "{surveyTypeDerived} 调查 的 卡片 布局",
"card_background_color": "卡片 的 背景 颜色",
"card_border_color": "卡片 的 边框 颜色",
"card_styling": "卡 样式",
"card_styling": "卡样式",
"casual": "休闲",
"caution_edit_duplicate": "复制 并 编辑",
"caution_edit_published_survey": "编辑 已 发布 的 survey?",
@@ -1327,6 +1328,7 @@
"css_selector": "CSS 选择器",
"cta_button_label": "“CTA”按钮标签",
"custom_hostname": "自 定 义 主 机 名",
"customize_survey_logo": "自定义调查 logo",
"darken_or_lighten_background_of_your_choice": "根据 您 的 选择 暗化 或 亮化 背景。",
"date_format": "日期格式",
"days_before_showing_this_survey_again": "在显示此调查之前,需等待的天数。",
@@ -1427,9 +1429,9 @@
"hide_back_button_description": "不 显示 调查 中 的 返回 按钮",
"hide_block_settings": "隐藏区块设置",
"hide_logo": "隐藏 徽标",
"hide_logo_from_survey": "隐藏此调查中的 logo",
"hide_progress_bar": "隐藏 进度 条",
"hide_question_settings": "隐藏问题设置",
"hide_the_logo_in_this_specific_survey": "隐藏此特定调查中的 logo",
"hostname": "主 机 名",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "在 {surveyTypeDerived} 调查 中,您 想要 卡片 多么 有趣",
"if_you_need_more_please": "如果你需要更多,请",
@@ -1476,6 +1478,7 @@
"load_segment": "载入 段落",
"logic_error_warning": "更改 将 导致 逻辑 错误",
"logic_error_warning_text": "更改问题类型 会 移除 此问题 的 逻辑条件",
"logo_settings": "Logo 设置",
"long_answer": "长答案",
"long_answer_toggle_description": "允许受访者填写较长的多行答案。",
"lower_label": "下限标签",
@@ -1508,6 +1511,7 @@
"overwrite_global_waiting_time": "设置自定义等待时间",
"overwrite_global_waiting_time_description": "仅为此调查覆盖项目配置。",
"overwrite_placement": "覆盖 放置",
"overwrite_survey_logo": "设置自定义调查 logo",
"overwrite_the_global_placement_of_the_survey": "覆盖 全局 调查 放置",
"pick_a_background_from_our_library_or_upload_your_own": "从我们的库中选择一种 背景 或 上传您自己的。",
"picture_idx": "图片 {idx}",

View File

@@ -422,6 +422,7 @@
"updated": "已更新",
"updated_at": "更新時間",
"upload": "上傳",
"upload_failed": "上傳失敗。請再試一次。",
"upload_input_description": "點擊或拖曳以上傳檔案。",
"url": "網址",
"user": "使用者",
@@ -1255,7 +1256,7 @@
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "如果用戶在特定秒數後未回應,則自動關閉問卷。",
"automatically_mark_the_survey_as_complete_after": "在指定時間後自動將問卷標記為完成",
"back_button_label": "「返回」按鈕標籤",
"background_styling": "背景樣式設定",
"background_styling": "背景樣式",
"block_duplicated": "區塊已複製。",
"bold": "粗體",
"brand_color": "品牌顏色",
@@ -1271,7 +1272,7 @@
"card_arrangement_for_survey_type_derived": "'{'surveyTypeDerived'}' 問卷的卡片排列",
"card_background_color": "卡片背景顏色",
"card_border_color": "卡片邊框顏色",
"card_styling": "卡片樣式設定",
"card_styling": "卡片樣式",
"casual": "隨意",
"caution_edit_duplicate": "複製 & 編輯",
"caution_edit_published_survey": "編輯已發佈的調查?",
@@ -1327,6 +1328,7 @@
"css_selector": "CSS 選取器",
"cta_button_label": "「CTA」按鈕標籤",
"custom_hostname": "自訂主機名稱",
"customize_survey_logo": "自訂問卷標誌",
"darken_or_lighten_background_of_your_choice": "變暗或變亮您選擇的背景。",
"date_format": "日期格式",
"days_before_showing_this_survey_again": "在顯示此問卷之前,需等待其他問卷顯示後的天數。",
@@ -1427,9 +1429,9 @@
"hide_back_button_description": "不要在問卷中顯示返回按鈕",
"hide_block_settings": "隱藏區塊設定",
"hide_logo": "隱藏標誌",
"hide_logo_from_survey": "隱藏此問卷的標誌",
"hide_progress_bar": "隱藏進度列",
"hide_question_settings": "隱藏問題設定",
"hide_the_logo_in_this_specific_survey": "在此特定問卷中隱藏標誌",
"hostname": "主機名稱",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "您希望 '{'surveyTypeDerived'}' 問卷中的卡片有多酷炫",
"if_you_need_more_please": "如果您需要更多,請",
@@ -1476,6 +1478,7 @@
"load_segment": "載入區隔",
"logic_error_warning": "變更將導致邏輯錯誤",
"logic_error_warning_text": "變更問題類型將會從此問題中移除邏輯條件",
"logo_settings": "標誌設定",
"long_answer": "長回答",
"long_answer_toggle_description": "允許受訪者撰寫較長的多行回答。",
"lower_label": "下標籤",
@@ -1508,6 +1511,7 @@
"overwrite_global_waiting_time": "設定自訂等待時間",
"overwrite_global_waiting_time_description": "僅覆蓋此問卷的專案設定。",
"overwrite_placement": "覆寫位置",
"overwrite_survey_logo": "設定自訂問卷標誌",
"overwrite_the_global_placement_of_the_survey": "覆寫問卷的整體位置",
"pick_a_background_from_our_library_or_upload_your_own": "從我們的媒體庫中選取背景或上傳您自己的背景。",
"picture_idx": "圖片 '{'idx'}'",

View File

@@ -28,7 +28,7 @@ export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdo
{enabledLanguages.map((surveyLanguage) => (
<button
key={surveyLanguage.language.code}
className="w-full rounded-md p-2 text-start hover:cursor-pointer hover:bg-slate-700"
className="w-full truncate rounded-md p-2 text-start hover:cursor-pointer hover:bg-slate-700"
onClick={() => {
setLanguage(surveyLanguage.language.code);
setShowLanguageSelect(false);

View File

@@ -48,29 +48,33 @@ export function LanguageIndicator({
<button
aria-expanded={showLanguageDropdown}
aria-haspopup="true"
className="relative z-20 flex items-center justify-center rounded-md bg-slate-900 p-1 px-2 text-xs text-white hover:bg-slate-700"
className="relative z-20 flex max-w-[120px] items-center justify-center rounded-md bg-slate-900 p-1 px-2 text-xs text-white hover:bg-slate-700"
onClick={toggleDropdown}
tabIndex={-1}
type="button">
{languageToBeDisplayed ? getLanguageLabel(languageToBeDisplayed.language.code, locale) : ""}
<ChevronDown className="ml-1 h-4 w-4" />
<span className="max-w-full truncate">
{languageToBeDisplayed ? getLanguageLabel(languageToBeDisplayed.language.code, locale) : ""}
</span>
<ChevronDown className="ml-1 h-4 w-4 flex-shrink-0" />
</button>
{showLanguageDropdown ? (
<div
className="absolute right-0 z-30 mt-1 max-h-64 space-y-2 overflow-auto rounded-md bg-slate-900 p-1 text-xs text-white"
className="absolute right-0 z-30 mt-1 max-h-64 w-48 space-y-2 overflow-auto rounded-md bg-slate-900 p-1 text-xs text-white"
ref={languageDropdownRef}>
{surveyLanguages.map(
(language) =>
language.language.code !== languageToBeDisplayed?.language.code &&
language.enabled && (
<button
className="block w-full rounded-sm p-1 text-left hover:bg-slate-700"
className="flex w-full rounded-sm p-1 text-left hover:bg-slate-700"
key={language.language.id}
onClick={() => {
changeLanguage(language);
}}
type="button">
{getLanguageLabel(language.language.code, locale)}
<span className="min-w-0 flex-1 truncate">
{getLanguageLabel(language.language.code, locale)}
</span>
</button>
)
)}

View File

@@ -154,7 +154,6 @@ export const ThemeStyling = ({
open={cardStylingOpen}
setOpen={setCardStylingOpen}
isSettingsPage
project={project}
surveyType={previewSurveyType}
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
/>

View File

@@ -19,8 +19,13 @@ export const createSurvey = async (
try {
const { createdBy, ...restSurveyBody } = surveyBody;
// empty languages array
if (!restSurveyBody.languages?.length) {
const hasLanguages = Array.isArray(restSurveyBody.languages)
? restSurveyBody.languages.length > 0
: restSurveyBody.languages &&
typeof restSurveyBody.languages === "object" &&
"create" in restSurveyBody.languages;
if (!hasLanguages) {
delete restSurveyBody.languages;
}

View File

@@ -133,13 +133,16 @@ export const BlockCard = ({
// A button label is invalid if it exists but doesn't have valid text for all enabled languages
const surveyLanguages = localSurvey.languages ?? [];
const hasInvalidButtonLabel =
block.buttonLabel !== undefined && !isLabelValidForAllLanguages(block.buttonLabel, surveyLanguages);
block.buttonLabel !== undefined &&
block.buttonLabel["default"]?.trim() !== "" &&
!isLabelValidForAllLanguages(block.buttonLabel, surveyLanguages);
// Check if back button label is invalid
// Back button label should exist for all blocks except the first one
const hasInvalidBackButtonLabel =
blockIdx > 0 &&
block.backButtonLabel !== undefined &&
block.backButtonLabel["default"]?.trim() !== "" &&
!isLabelValidForAllLanguages(block.backButtonLabel, surveyLanguages);
// Block should be highlighted if it has invalid elements OR invalid button labels
@@ -291,28 +294,28 @@ export const BlockCard = ({
open={!isBlockCollapsed}
onOpenChange={() => setIsBlockCollapsed(!isBlockCollapsed)}
className={cn(isBlockCollapsed ? "h-full" : "")}>
<Collapsible.CollapsibleTrigger
asChild
className="block h-full w-full cursor-pointer hover:bg-slate-100">
<div className="flex h-full items-center justify-between px-4 py-2">
<div className="flex items-center gap-2">
<div>
<h4 className="text-sm font-medium text-slate-700">{block.name}</h4>
<p className="text-xs text-slate-500">
{blockElementsCount} {blockElementsCountText}
</p>
<Collapsible.CollapsibleTrigger asChild>
<div className="block h-full w-full cursor-pointer hover:bg-slate-100">
<div className="flex h-full items-center justify-between px-4 py-2">
<div className="flex items-center gap-2">
<div>
<h4 className="text-sm font-medium text-slate-700">{block.name}</h4>
<p className="text-xs text-slate-500">
{blockElementsCount} {blockElementsCountText}
</p>
</div>
</div>
<div>
<BlockMenu
isFirstBlock={blockIdx === 0}
isLastBlock={blockIdx === totalBlocks - 1}
isOnlyBlock={totalBlocks === 1}
onDuplicate={() => duplicateBlock(block.id)}
onDelete={() => deleteBlock(block.id)}
onMoveUp={() => moveBlock(block.id, "up")}
onMoveDown={() => moveBlock(block.id, "down")}
/>
</div>
</div>
<div>
<BlockMenu
isFirstBlock={blockIdx === 0}
isLastBlock={blockIdx === totalBlocks - 1}
isOnlyBlock={totalBlocks === 1}
onDuplicate={() => duplicateBlock(block.id)}
onDelete={() => deleteBlock(block.id)}
onMoveUp={() => moveBlock(block.id, "up")}
onMoveDown={() => moveBlock(block.id, "down")}
/>
</div>
</div>
</Collapsible.CollapsibleTrigger>

View File

@@ -513,8 +513,8 @@ export const ElementsView = ({
id: newBlockId,
name: getBlockName(index ?? prevSurvey.blocks.length),
elements: [{ ...updatedElement, isDraft: true }],
buttonLabel: createI18nString(t("templates.next"), []),
backButtonLabel: createI18nString(t("templates.back"), []),
buttonLabel: createI18nString("", []),
backButtonLabel: createI18nString("", []),
};
return {

View File

@@ -0,0 +1,262 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as Collapsible from "@radix-ui/react-collapsible";
import { CheckIcon } from "lucide-react";
import Image from "next/image";
import React, { ChangeEvent, useRef, useState } from "react";
import { UseFormReturn } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TProjectStyling } from "@formbricks/types/project";
import { TSurveyStyling } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
import { handleFileUpload } from "@/modules/storage/file-upload";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { ColorPicker } from "@/modules/ui/components/color-picker";
import { FileInput } from "@/modules/ui/components/file-input";
import { FormControl, FormDescription, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { showStorageNotConfiguredToast } from "@/modules/ui/components/storage-not-configured-toast/lib/utils";
import { Switch } from "@/modules/ui/components/switch";
type LogoSettingsCardProps = {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
environmentId: string;
form: UseFormReturn<TProjectStyling | TSurveyStyling>;
disabled?: boolean;
isStorageConfigured: boolean;
};
export const LogoSettingsCard = ({
open,
setOpen,
environmentId,
form,
disabled = false,
isStorageConfigured,
}: LogoSettingsCardProps) => {
const { t } = useTranslation();
const [parent] = useAutoAnimate();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState(false);
const logoUrl = form.watch("logo")?.url;
const logoBgColor = form.watch("logo")?.bgColor;
const isBgColorEnabled = !!logoBgColor;
const isLogoHidden = form.watch("isLogoHidden");
const setLogoUrl = (url: string | undefined) => {
const currentLogo = form.getValues("logo");
form.setValue("logo", url ? { ...currentLogo, url } : undefined);
};
const setLogoBgColor = (bgColor: string | undefined) => {
const currentLogo = form.getValues("logo");
form.setValue("logo", {
...currentLogo,
url: logoUrl,
bgColor,
});
};
const handleFileInputChange = async (files: string[]) => {
if (files.length > 0) {
setLogoUrl(files[0]);
}
};
const handleHiddenFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
if (!isStorageConfigured) {
showStorageNotConfiguredToast();
return;
}
const file = event.target.files?.[0];
if (!file) return;
setIsLoading(true);
try {
const uploadResult = await handleFileUpload(file, environmentId);
if (uploadResult.error) {
toast.error(t("common.upload_failed"));
return;
}
setLogoUrl(uploadResult.url);
} catch {
toast.error(t("common.upload_failed"));
} finally {
setIsLoading(false);
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
const handleRemoveLogo = () => {
form.setValue("logo", undefined);
};
const toggleBackgroundColor = (enabled: boolean) => {
setLogoBgColor(enabled ? logoBgColor || "#f8f8f8" : undefined);
};
const handleBgColorChange = (color: string) => {
setLogoBgColor(color);
};
return (
<Collapsible.Root
open={open}
onOpenChange={(openState) => {
if (disabled) return;
setOpen(openState);
}}
className="w-full rounded-lg border border-slate-300 bg-white">
<Collapsible.CollapsibleTrigger
asChild
disabled={disabled}
className={cn(
"w-full cursor-pointer rounded-lg hover:bg-slate-50",
disabled && "cursor-not-allowed opacity-60 hover:bg-white"
)}>
<div className="inline-flex w-full px-4 py-4">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
/>
</div>
<div>
<p className="text-base font-semibold text-slate-800">
{t("environments.surveys.edit.logo_settings")}
</p>
<p className="mt-1 text-sm text-slate-500">
{t("environments.surveys.edit.customize_survey_logo")}
</p>
</div>
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="flex flex-col" ref={parent}>
<hr className="py-1 text-slate-600" />
<div className="flex flex-col gap-6 p-6 pt-2">
<FormField
control={form.control}
name="isLogoHidden"
render={({ field }) => (
<FormItem className="flex items-center gap-2 space-y-0">
<FormControl>
<Switch checked={!!field.value} onCheckedChange={field.onChange} disabled={disabled} />
</FormControl>
<div>
<FormLabel className="text-base font-semibold text-slate-900">
{t("environments.surveys.edit.hide_logo")}
</FormLabel>
<FormDescription className="text-sm text-slate-800">
{t("environments.surveys.edit.hide_logo_from_survey")}
</FormDescription>
</div>
</FormItem>
)}
/>
{!isLogoHidden && (
<div className="space-y-4">
<div className="font-medium text-slate-800">
{t("environments.surveys.edit.overwrite_survey_logo")}
</div>
{/* Hidden file input for replacing logo */}
<Input
ref={fileInputRef}
type="file"
accept="image/jpeg, image/png, image/webp, image/heic"
className="hidden"
disabled={disabled}
onChange={handleHiddenFileChange}
/>
{logoUrl ? (
<>
<div className="flex items-center gap-4">
<Image
src={logoUrl}
alt="Survey Logo"
width={256}
height={56}
style={{ backgroundColor: logoBgColor || undefined }}
className="h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
/>
</div>
<div className="flex gap-2">
<Button
type="button"
onClick={() => {
if (!isStorageConfigured) {
showStorageNotConfiguredToast();
return;
}
fileInputRef.current?.click();
}}
variant="secondary"
size="sm"
disabled={disabled || isLoading}>
{t("environments.project.look.replace_logo")}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
onClick={handleRemoveLogo}
disabled={disabled}>
{t("environments.project.look.remove_logo")}
</Button>
</div>
<AdvancedOptionToggle
isChecked={isBgColorEnabled}
onToggle={toggleBackgroundColor}
htmlId="surveyLogoBgColor"
title={t("environments.project.look.add_background_color")}
description={t("environments.project.look.add_background_color_description")}
childBorder
customContainerClass="p-0"
childrenContainerClass="overflow-visible"
disabled={disabled}>
{isBgColorEnabled && (
<div className="px-2">
<ColorPicker
color={logoBgColor || "#f8f8f8"}
onChange={handleBgColorChange}
disabled={disabled}
/>
</div>
)}
</AdvancedOptionToggle>
</>
) : (
<FileInput
id="survey-logo-input"
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
environmentId={environmentId}
onFileUpload={handleFileInputChange}
disabled={disabled}
maxSizeInMB={5}
isStorageConfigured={isStorageConfigured}
/>
)}
</div>
)}
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
);
};

View File

@@ -11,6 +11,7 @@ import { TProjectStyling } from "@formbricks/types/project";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { defaultStyling } from "@/lib/styling/constants";
import { FormStylingSettings } from "@/modules/survey/editor/components/form-styling-settings";
import { LogoSettingsCard } from "@/modules/survey/editor/components/logo-settings-card";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
import { BackgroundStylingCard } from "@/modules/ui/components/background-styling-card";
import { Button } from "@/modules/ui/components/button";
@@ -64,6 +65,7 @@ export const StylingView = ({
const setOverwriteThemeStyling = (value: boolean) => form.setValue("overwriteThemeStyling", value);
const [formStylingOpen, setFormStylingOpen] = useState(false);
const [logoSettingsOpen, setLogoSettingsOpen] = useState(false);
const [cardStylingOpen, setCardStylingOpen] = useState(false);
const [stylingOpen, setStylingOpen] = useState(false);
const [confirmResetStylingModalOpen, setConfirmResetStylingModalOpen] = useState(false);
@@ -88,6 +90,7 @@ export const StylingView = ({
useEffect(() => {
if (!overwriteThemeStyling) {
setFormStylingOpen(false);
setLogoSettingsOpen(false);
setCardStylingOpen(false);
setStylingOpen(false);
}
@@ -198,21 +201,31 @@ export const StylingView = ({
setOpen={setCardStylingOpen}
surveyType={localSurvey.type}
disabled={!overwriteThemeStyling}
project={project}
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
/>
{localSurvey.type === "link" && (
<BackgroundStylingCard
open={stylingOpen}
setOpen={setStylingOpen}
environmentId={environmentId}
colors={colors}
disabled={!overwriteThemeStyling}
isUnsplashConfigured={isUnsplashConfigured}
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
isStorageConfigured={isStorageConfigured}
/>
<>
<BackgroundStylingCard
open={stylingOpen}
setOpen={setStylingOpen}
environmentId={environmentId}
colors={colors}
disabled={!overwriteThemeStyling}
isUnsplashConfigured={isUnsplashConfigured}
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
isStorageConfigured={isStorageConfigured}
/>
<LogoSettingsCard
open={logoSettingsOpen}
setOpen={setLogoSettingsOpen}
disabled={!overwriteThemeStyling}
environmentId={environmentId}
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
isStorageConfigured={isStorageConfigured}
/>
</>
)}
{!isCxMode && (

View File

@@ -79,7 +79,9 @@ export const LinkSurveyWrapper = ({
styling={styling}
onBackgroundLoaded={handleBackgroundLoaded}>
<div className="flex max-h-dvh min-h-dvh items-center justify-center overflow-clip">
{!styling.isLogoHidden && project.logo?.url && <ClientLogo projectLogo={project.logo} />}
{!styling.isLogoHidden && (project.logo?.url || styling.logo?.url) && (
<ClientLogo projectLogo={project.logo} surveyLogo={styling.logo} />
)}
<div className="h-full w-full max-w-4xl space-y-6 px-1.5">
{isPreview && (
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">

View File

@@ -3,6 +3,7 @@
import { z } from "zod";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZSurveyFilterCriteria } from "@formbricks/types/surveys/types";
import { getProject } from "@/lib/project/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
@@ -15,12 +16,31 @@ import {
} from "@/lib/utils/helper";
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getSurvey as getSurveyFull } from "@/modules/survey/lib/survey";
import { getProjectIdIfEnvironmentExists } from "@/modules/survey/list/lib/environment";
import { ZSurveyExportPayload, transformSurveyForExport } from "@/modules/survey/list/lib/export-survey";
import {
type TSurveyLanguageConnection,
addLanguageLabels,
mapLanguages,
mapTriggers,
normalizeLanguagesForCreation,
parseSurveyPayload,
persistSurvey,
resolveImportCapabilities,
} from "@/modules/survey/list/lib/import";
import {
buildImportWarnings,
detectImagesInSurvey,
getLanguageNames,
stripUnavailableFeatures,
} from "@/modules/survey/list/lib/import-helpers";
import { getUserProjects } from "@/modules/survey/list/lib/project";
import {
copySurveyToOtherEnvironment,
deleteSurvey,
getSurvey,
getSurvey as getSurveyMinimal,
getSurveys,
} from "@/modules/survey/list/lib/survey";
@@ -47,7 +67,35 @@ export const getSurveyAction = authenticatedActionClient
],
});
return await getSurvey(parsedInput.surveyId);
return await getSurveyMinimal(parsedInput.surveyId);
});
const ZExportSurveyAction = z.object({
surveyId: z.string().cuid2(),
});
export const exportSurveyAction = authenticatedActionClient
.schema(ZExportSurveyAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
const survey = await getSurveyFull(parsedInput.surveyId);
return transformSurveyForExport(survey);
});
const ZCopySurveyToOtherEnvironmentAction = z.object({
@@ -92,7 +140,6 @@ export const copySurveyToOtherEnvironmentAction = authenticatedActionClient
);
}
// authorization check for source environment
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: sourceEnvironmentOrganizationId,
@@ -109,7 +156,6 @@ export const copySurveyToOtherEnvironmentAction = authenticatedActionClient
],
});
// authorization check for target environment
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: targetEnvironmentOrganizationId,
@@ -263,3 +309,168 @@ export const getSurveysAction = authenticatedActionClient
parsedInput.filterCriteria
);
});
const ZValidateSurveyImportAction = z.object({
surveyData: ZSurveyExportPayload,
environmentId: z.string().cuid2(),
});
export const validateSurveyImportAction = authenticatedActionClient
.schema(ZValidateSurveyImportAction)
.action(async ({ ctx, parsedInput }) => {
// Step 1: Parse and validate payload structure
const parseResult = parseSurveyPayload(parsedInput.surveyData);
if ("error" in parseResult) {
return {
valid: false,
errors:
parseResult.details && parseResult.details.length > 0
? [parseResult.error, ...parseResult.details]
: [parseResult.error],
warnings: [],
infos: [],
surveyName: parsedInput.surveyData.data.name || "",
};
}
const { surveyInput, exportedLanguages, triggers } = parseResult;
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
// Trigger validation is now handled by Zod schema validation
const languageCodes = exportedLanguages.map((l) => l.code).filter(Boolean);
if (languageCodes.length > 0) {
const project = await getProject(projectId);
const existingLanguageCodes = project?.languages.map((l) => l.code) || [];
const missingLanguages = languageCodes.filter((code: string) => !existingLanguageCodes.includes(code));
if (missingLanguages.length > 0) {
const languageNames = getLanguageNames(missingLanguages);
return {
valid: false,
errors: [
`Before you can continue, please setup the following languages in your Project Configuration: ${languageNames.join(", ")}`,
],
warnings: [],
infos: [],
surveyName: surveyInput.name || "",
};
}
}
const warnings = await buildImportWarnings(surveyInput, organizationId);
const infos: string[] = [];
const hasImages = detectImagesInSurvey(surveyInput);
if (hasImages) {
warnings.push("import_warning_images");
}
if (triggers && triggers.length > 0) {
infos.push("import_info_triggers");
}
infos.push("import_info_quotas");
return {
valid: true,
errors: [],
warnings,
infos,
surveyName: surveyInput.name || "Imported Survey",
};
});
const ZImportSurveyAction = z.object({
surveyData: ZSurveyExportPayload,
environmentId: z.string().cuid2(),
newName: z.string(),
});
export const importSurveyAction = authenticatedActionClient
.schema(ZImportSurveyAction)
.action(async ({ ctx, parsedInput }) => {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
// Step 1: Parse and validate survey payload
const parseResult = parseSurveyPayload(parsedInput.surveyData);
if ("error" in parseResult) {
const errorMessage =
parseResult.details && parseResult.details.length > 0
? `${parseResult.error}:\n${parseResult.details.join("\n")}`
: parseResult.error;
throw new Error(`Validation failed: ${errorMessage}`);
}
const { surveyInput, exportedLanguages, triggers } = parseResult;
const capabilities = await resolveImportCapabilities(organizationId);
const triggerResult = await mapTriggers(triggers, parsedInput.environmentId);
const cleanedSurvey = await stripUnavailableFeatures(surveyInput, parsedInput.environmentId);
let mappedLanguages: TSurveyLanguageConnection | undefined = undefined;
let languageCodes: string[] = [];
if (exportedLanguages.length > 0 && capabilities.hasMultiLanguage) {
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
const langResult = await mapLanguages(exportedLanguages, projectId);
if (langResult.mapped.length > 0) {
mappedLanguages = normalizeLanguagesForCreation(langResult.mapped);
languageCodes = exportedLanguages.filter((l) => !l.default).map((l) => l.code);
}
}
const surveyWithTranslations = addLanguageLabels(cleanedSurvey, languageCodes);
const result = await persistSurvey(
parsedInput.environmentId,
surveyWithTranslations,
parsedInput.newName,
ctx.user.id,
triggerResult.mapped,
mappedLanguages
);
return result;
} catch (error) {
throw error;
}
});

View File

@@ -0,0 +1,26 @@
"use client";
import { UploadIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import { ImportSurveyModal } from "./import-survey-modal";
interface ImportSurveyButtonProps {
environmentId: string;
}
export const ImportSurveyButton = ({ environmentId }: ImportSurveyButtonProps) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<>
<Button size="sm" variant="secondary" onClick={() => setOpen(true)}>
<UploadIcon className="mr-2 h-4 w-4" />
{t("environments.surveys.import_survey")}
</Button>
<ImportSurveyModal environmentId={environmentId} open={open} setOpen={setOpen} />
</>
);
};

View File

@@ -0,0 +1,320 @@
"use client";
import { ArrowUpFromLineIcon, CheckIcon } from "lucide-react";
import React, { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { importSurveyAction, validateSurveyImportAction } from "@/modules/survey/list/actions";
import { type TSurveyExportPayload } from "@/modules/survey/list/lib/export-survey";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
interface ImportSurveyModalProps {
environmentId: string;
open: boolean;
setOpen: (open: boolean) => void;
}
export const ImportSurveyModal = ({ environmentId, open, setOpen }: ImportSurveyModalProps) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [fileName, setFileName] = useState<string>("");
const [surveyData, setSurveyData] = useState<TSurveyExportPayload | null>(null);
const [validationErrors, setValidationErrors] = useState<string[]>([]);
const [validationWarnings, setValidationWarnings] = useState<string[]>([]);
const [validationInfos, setValidationInfos] = useState<string[]>([]);
const [newName, setNewName] = useState("");
const [isValid, setIsValid] = useState(false);
const resetState = () => {
setFileName("");
setSurveyData(null);
setValidationErrors([]);
setValidationWarnings([]);
setValidationInfos([]);
setNewName("");
setIsLoading(false);
setIsValid(false);
};
const onOpenChange = (open: boolean) => {
if (!open) {
resetState();
}
setOpen(open);
};
const processJSONFile = async (file: File) => {
if (!file) return;
if (file.type !== "application/json" && !file.name.endsWith(".json")) {
toast.error(t("environments.surveys.import_error_invalid_json"));
setValidationErrors([t("environments.surveys.import_error_invalid_json")]);
setFileName("");
setIsValid(false);
return;
}
setFileName(file.name);
setIsLoading(true);
const reader = new FileReader();
reader.onload = async (event) => {
try {
const json = JSON.parse(event.target?.result as string);
setSurveyData(json);
const result = await validateSurveyImportAction({
surveyData: json,
environmentId,
});
if (result?.data) {
setValidationErrors(result.data.errors || []);
setValidationWarnings(result.data.warnings || []);
setValidationInfos(result.data.infos || []);
setIsValid(result.data.valid);
if (result.data.valid) {
setNewName(result.data.surveyName + " (imported)");
}
} else if (result?.serverError) {
setValidationErrors([result.serverError]);
setValidationWarnings([]);
setValidationInfos([]);
setIsValid(false);
}
} catch (error) {
toast.error(t("environments.surveys.import_error_invalid_json"));
setValidationErrors([t("environments.surveys.import_error_invalid_json")]);
setValidationWarnings([]);
setValidationInfos([]);
setIsValid(false);
} finally {
setIsLoading(false);
}
};
reader.readAsText(file);
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
processJSONFile(file);
}
};
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
};
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
const file = e.dataTransfer.files[0];
if (file) {
processJSONFile(file);
}
};
const handleImport = async () => {
if (!surveyData) {
toast.error(t("environments.surveys.import_survey_error"));
return;
}
setIsLoading(true);
try {
const result = await importSurveyAction({
surveyData,
environmentId,
newName,
});
if (result?.data) {
toast.success(t("environments.surveys.import_survey_success"));
onOpenChange(false);
window.location.href = `/environments/${environmentId}/surveys/${result.data.surveyId}/edit`;
} else if (result?.serverError) {
console.error("[Import Survey] Server error:", result.serverError);
toast.error(result.serverError);
} else {
console.error("[Import Survey] Unknown error - no data or serverError returned");
toast.error(t("environments.surveys.import_survey_error"));
}
} catch (error) {
console.error("[Import Survey] Exception caught:", error);
const errorMessage =
error instanceof Error ? error.message : t("environments.surveys.import_survey_error");
toast.error(errorMessage);
} finally {
setIsLoading(false);
}
};
const renderUploadSection = () => {
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<LoadingSpinner />
</div>
);
}
if (!fileName) {
return (
<label
htmlFor="import-file"
className={cn(
"relative flex cursor-pointer flex-col items-center justify-center rounded-lg hover:bg-slate-100"
)}
onDragOver={handleDragOver}
onDrop={handleDrop}>
<div className="flex flex-col items-center justify-center pb-6 pt-5">
<ArrowUpFromLineIcon className="h-6 text-slate-500" />
<p className="mt-2 text-center text-sm text-slate-500">
<span className="font-semibold">{t("common.upload_input_description")}</span>
</p>
<p className="text-xs text-slate-400">.json files only</p>
<Input
id="import-file"
type="file"
accept=".json"
className="hidden"
onChange={handleFileChange}
/>
</div>
</label>
);
}
return (
<div className="flex flex-col items-center gap-4 py-4">
<div className="flex items-center gap-2">
<CheckIcon className="h-5 w-5 text-green-600" />
<span className="text-sm font-medium text-slate-700">{fileName}</span>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => {
resetState();
document.getElementById("import-file-retry")?.click();
}}>
{t("environments.contacts.upload_contacts_modal_pick_different_file")}
</Button>
<Input
id="import-file-retry"
type="file"
accept=".json"
className="hidden"
onChange={handleFileChange}
/>
</div>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>{t("environments.surveys.import_survey")}</DialogTitle>
<DialogDescription>{t("environments.surveys.import_survey_description")}</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="flex flex-col gap-4">
<div className="rounded-md border-2 border-dashed border-slate-300 bg-slate-50 p-4">
{renderUploadSection()}
</div>
{validationErrors.length > 0 && (
<Alert variant="error">
<AlertTitle>{t("environments.surveys.import_survey_errors")}</AlertTitle>
<AlertDescription className="max-h-60 overflow-y-auto">
<ul className="space-y-2 text-sm">
{validationErrors.map((error, i) => {
// Check if the error contains a field path (format: 'Field "path":')
const fieldMatch = error.match(/^Field "([^"]+)": (.+)$/);
if (fieldMatch) {
return (
<li key={i} className="flex flex-col gap-1">
<code className="rounded bg-red-50 px-1.5 py-0.5 font-mono text-xs text-red-800">
{fieldMatch[1]}
</code>
<span className="text-slate-700">{fieldMatch[2]}</span>
</li>
);
}
return (
<li key={i} className="text-slate-700">
{error}
</li>
);
})}
</ul>
</AlertDescription>
</Alert>
)}
{validationWarnings.length > 0 && (
<Alert variant="warning">
<AlertTitle>{t("environments.surveys.import_survey_warnings")}</AlertTitle>
<AlertDescription className="max-h-60 overflow-y-auto">
<ul className="list-disc pl-4 text-sm">
{validationWarnings.map((warningKey, i) => (
<li key={i}>{t(`environments.surveys.${warningKey}`)}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{validationInfos.length > 0 && (
<Alert variant="info">
<AlertDescription className="max-h-60 overflow-y-auto">
<ul className="list-disc pl-4 text-sm">
{validationInfos.map((infoKey, i) => (
<li key={i}>{t(`environments.surveys.${infoKey}`)}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{isValid && fileName && (
<div className="space-y-2">
<Label htmlFor="survey-name">{t("environments.surveys.import_survey_name_label")}</Label>
<Input id="survey-name" value={newName} onChange={(e) => setNewName(e.target.value)} />
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
{t("common.cancel")}
</Button>
<Button
onClick={handleImport}
loading={isLoading}
disabled={!isValid || !fileName || validationErrors.length > 0 || isLoading}>
{t("environments.surveys.import_survey_import")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -3,6 +3,7 @@
import {
ArrowUpFromLineIcon,
CopyIcon,
DownloadIcon,
EyeIcon,
LinkIcon,
MoreVertical,
@@ -22,8 +23,10 @@ import { copySurveyLink } from "@/modules/survey/lib/client-utils";
import {
copySurveyToOtherEnvironmentAction,
deleteSurveyAction,
exportSurveyAction,
getSurveyAction,
} from "@/modules/survey/list/actions";
import { downloadSurveyJson } from "@/modules/survey/list/lib/download-survey";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
@@ -55,7 +58,7 @@ export const SurveyDropDownMenu = ({
onSurveysCopied,
}: SurveyDropDownMenuProps) => {
const { t } = useTranslation();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const [isCopyFormOpen, setIsCopyFormOpen] = useState(false);
@@ -73,6 +76,7 @@ export const SurveyDropDownMenu = ({
deleteSurvey(surveyId);
toast.success(t("environments.surveys.survey_deleted_successfully"));
} catch (error) {
logger.error(error);
toast.error(t("environments.surveys.error_deleting_survey"));
} finally {
setLoading(false);
@@ -83,7 +87,6 @@ export const SurveyDropDownMenu = ({
try {
e.preventDefault();
setIsDropDownOpen(false);
// For single-use surveys, this button is disabled, so we just copy the base link
const copiedLink = copySurveyLink(surveyLink);
navigator.clipboard.writeText(copiedLink);
toast.success(t("common.copied_to_clipboard"));
@@ -114,6 +117,7 @@ export const SurveyDropDownMenu = ({
toast.error(errorMessage);
}
} catch (error) {
logger.error(error);
toast.error(t("environments.surveys.survey_duplication_error"));
}
setLoading(false);
@@ -125,6 +129,32 @@ export const SurveyDropDownMenu = ({
setIsCautionDialogOpen(true);
};
const handleExportSurvey = async () => {
const exportPromise = exportSurveyAction({ surveyId: survey.id }).then((result) => {
if (result?.data) {
downloadSurveyJson(survey.name, JSON.stringify(result.data, null, 2));
return result.data;
} else if (result?.serverError) {
throw new Error(result.serverError);
}
throw new Error(t("environments.surveys.export_survey_error"));
});
toast.promise(exportPromise, {
loading: t("environments.surveys.export_survey_loading"),
success: t("environments.surveys.export_survey_success"),
error: (err) => err.message || t("environments.surveys.export_survey_error"),
});
try {
await exportPromise;
} catch (error) {
logger.error(error);
} finally {
setIsDropDownOpen(false);
}
};
return (
<div
id={`${survey.name.toLowerCase().split(" ").join("-")}-survey-actions`}
@@ -185,6 +215,21 @@ export const SurveyDropDownMenu = ({
</button>
</DropdownMenuItem>
)}
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
disabled={loading}
onClick={(e) => {
e.preventDefault();
handleExportSurvey();
}}>
<DownloadIcon className="mr-2 h-4 w-4" />
{t("environments.surveys.export_survey")}
</button>
</DropdownMenuItem>
{survey.type === "link" && survey.status !== "draft" && (
<>
<DropdownMenuItem>
@@ -229,7 +274,7 @@ export const SurveyDropDownMenu = ({
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
setDeleteDialogOpen(true);
setIsDeleteDialogOpen(true);
}}>
<TrashIcon className="mr-2 h-4 w-4" />
{t("common.delete")}
@@ -244,7 +289,7 @@ export const SurveyDropDownMenu = ({
<DeleteDialog
deleteWhat="Survey"
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
setOpen={setIsDeleteDialogOpen}
onDelete={() => handleDeleteSurvey(survey.id)}
text={t("environments.surveys.delete_survey_and_responses_warning")}
isDeleting={loading}

View File

@@ -0,0 +1,10 @@
export const downloadSurveyJson = (surveyName: string, jsonContent: string) => {
const blob = new Blob([jsonContent], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
const timestamp = new Date().toISOString().split("T")[0];
link.href = url;
link.download = `${surveyName}-export-${timestamp}.json`;
link.click();
URL.revokeObjectURL(url);
};

View File

@@ -0,0 +1,145 @@
import { z } from "zod";
import { ZSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { ZActionClassNoCodeConfig, ZActionClassType } from "@formbricks/types/action-classes";
import { type TSurvey } from "@formbricks/types/surveys/types";
// Schema for exported action class (subset of full action class)
export const ZExportedActionClass = z.object({
name: z.string(),
description: z.string().nullable(),
type: ZActionClassType,
key: z.string().nullable(),
noCodeConfig: ZActionClassNoCodeConfig.nullable(),
});
export type TExportedActionClass = z.infer<typeof ZExportedActionClass>;
// Schema for exported trigger
export const ZExportedTrigger = z.object({
actionClass: ZExportedActionClass,
});
export type TExportedTrigger = z.infer<typeof ZExportedTrigger>;
// Schema for exported language
export const ZExportedLanguage = z.object({
code: z.string(),
enabled: z.boolean(),
default: z.boolean(),
});
export type TExportedLanguage = z.infer<typeof ZExportedLanguage>;
// Current export format version
export const SURVEY_EXPORT_VERSION = "1.0.0";
// Survey data schema - the actual survey content (nested under "data" in export)
export const ZSurveyExportData = z.object({
// Use the input shape from ZSurveyCreateInput and override what we need
name: z.string(),
type: z.string().optional(),
status: z.string().optional(),
displayOption: z.string().optional(),
environmentId: z.string().optional(),
createdBy: z.string().optional(),
autoClose: z.number().nullable().optional(),
recontactDays: z.number().nullable().optional(),
displayLimit: z.number().nullable().optional(),
delay: z.number().optional(),
displayPercentage: z.number().nullable().optional(),
autoComplete: z.number().nullable().optional(),
isVerifyEmailEnabled: z.boolean().optional(),
isSingleResponsePerEmailEnabled: z.boolean().optional(),
isBackButtonHidden: z.boolean().optional(),
pin: z.string().nullable().optional(),
welcomeCard: z.any().optional(),
blocks: z.array(z.any()),
endings: z.array(z.any()).optional(),
hiddenFields: z.any().optional(),
variables: z.array(z.any()).optional(),
surveyClosedMessage: z.any().optional(),
styling: z.any().optional(),
showLanguageSwitch: z.boolean().nullable().optional(),
recaptcha: z.any().optional(),
metadata: z.any().optional(),
triggers: z.array(ZExportedTrigger).default([]),
languages: z.array(ZExportedLanguage).default([]),
followUps: z.array(ZSurveyFollowUp.omit({ createdAt: true, updatedAt: true })).default([]),
});
export type TSurveyExportData = z.infer<typeof ZSurveyExportData>;
// Full export payload with version and metadata wrapper
export const ZSurveyExportPayload = z.object({
version: z.string(),
exportDate: z.string().datetime(),
data: ZSurveyExportData,
});
export type TSurveyExportPayload = z.infer<typeof ZSurveyExportPayload>;
export const transformSurveyForExport = (survey: TSurvey): TSurveyExportPayload => {
const surveyData: TSurveyExportData = {
name: survey.name,
type: survey.type,
status: survey.status,
displayOption: survey.displayOption,
autoClose: survey.autoClose,
recontactDays: survey.recontactDays,
displayLimit: survey.displayLimit,
delay: survey.delay,
displayPercentage: survey.displayPercentage,
autoComplete: survey.autoComplete,
isVerifyEmailEnabled: survey.isVerifyEmailEnabled,
isSingleResponsePerEmailEnabled: survey.isSingleResponsePerEmailEnabled,
isBackButtonHidden: survey.isBackButtonHidden,
pin: survey.pin,
welcomeCard: survey.welcomeCard,
blocks: survey.blocks,
endings: survey.endings,
hiddenFields: survey.hiddenFields,
variables: survey.variables,
surveyClosedMessage: survey.surveyClosedMessage,
styling: survey.styling,
showLanguageSwitch: survey.showLanguageSwitch,
recaptcha: survey.recaptcha,
metadata: survey.metadata,
triggers:
survey.triggers?.map(
(t): TExportedTrigger => ({
actionClass: {
name: t.actionClass.name,
description: t.actionClass.description,
type: t.actionClass.type,
key: t.actionClass.key,
noCodeConfig: t.actionClass.noCodeConfig,
},
})
) ?? [],
languages:
survey.languages?.map(
(l): TExportedLanguage => ({
enabled: l.enabled,
default: l.default,
code: l.language.code,
})
) ?? [],
followUps:
survey.followUps?.map((f) => ({
id: f.id,
surveyId: f.surveyId,
name: f.name,
trigger: f.trigger,
action: f.action,
})) ?? [],
};
return {
version: SURVEY_EXPORT_VERSION,
exportDate: new Date().toISOString(),
data: surveyData,
};
};

View File

@@ -0,0 +1,119 @@
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { iso639Languages } from "@/lib/i18n/utils";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { type TExportedLanguage, type TExportedTrigger } from "./export-survey";
import {
type TMappedTrigger,
type TSurveyLanguageConnection,
mapLanguages,
mapTriggers,
normalizeLanguagesForCreation,
resolveImportCapabilities,
stripUnavailableFeatures as stripFeatures,
} from "./import";
export const getLanguageNames = (languageCodes: string[]): string[] => {
return languageCodes.map((code) => {
const language = iso639Languages.find((lang) => lang.alpha2 === code);
return language ? language.label["en-US"] : code;
});
};
export const mapExportedLanguagesToPrismaCreate = async (
exportedLanguages: TExportedLanguage[],
projectId: string
): Promise<TSurveyLanguageConnection | undefined> => {
const result = await mapLanguages(exportedLanguages, projectId);
return normalizeLanguagesForCreation(result.mapped);
};
export const mapOrCreateActionClasses = async (
importedTriggers: TExportedTrigger[],
environmentId: string
): Promise<TMappedTrigger[]> => {
const result = await mapTriggers(importedTriggers, environmentId);
return result.mapped;
};
export const stripUnavailableFeatures = async (
survey: TSurveyCreateInput,
environmentId: string
): Promise<TSurveyCreateInput> => {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const capabilities = await resolveImportCapabilities(organizationId);
return stripFeatures(survey, capabilities);
};
export const buildImportWarnings = async (
survey: TSurveyCreateInput,
organizationId: string
): Promise<string[]> => {
const warnings: string[] = [];
if (survey.languages?.length) {
try {
await checkMultiLanguagePermission(organizationId);
} catch (e) {
warnings.push("import_warning_multi_language");
}
}
if (survey.followUps?.length) {
let hasFollowUps = false;
try {
const organizationBilling = await getOrganizationBilling(organizationId);
if (organizationBilling) {
hasFollowUps = await getSurveyFollowUpsPermission(organizationBilling.plan);
}
} catch (e) {}
if (!hasFollowUps) {
warnings.push("import_warning_follow_ups");
}
}
if (survey.recaptcha?.enabled) {
try {
await checkSpamProtectionPermission(organizationId);
} catch (e) {
warnings.push("import_warning_recaptcha");
}
}
if (survey.segment) {
warnings.push("import_warning_segments");
}
if (survey.triggers?.length) {
warnings.push("import_warning_action_classes");
}
return warnings;
};
export const detectImagesInSurvey = (survey: TSurveyCreateInput): boolean => {
if (survey.welcomeCard?.fileUrl || survey.welcomeCard?.videoUrl) return true;
// Check blocks for images
if (survey.blocks) {
for (const block of survey.blocks) {
for (const element of block.elements) {
if (element.imageUrl || element.videoUrl) return true;
if (element.type === "pictureSelection" && element.choices?.some((c) => c.imageUrl)) {
return true;
}
}
}
}
if (survey.endings && survey.endings.length > 0) {
for (const e of survey.endings) {
if (e.type === "endScreen" && (e.imageUrl || e.videoUrl)) return true;
}
}
return false;
};

View File

@@ -0,0 +1,11 @@
export { mapLanguages, type TMappedLanguage } from "./map-languages";
export { mapTriggers, type TMappedTrigger } from "./map-triggers";
export {
addLanguageLabels,
normalizeLanguagesForCreation,
stripUnavailableFeatures,
type TSurveyLanguageConnection,
} from "./normalize-survey";
export { parseSurveyPayload, type TParsedPayload } from "./parse-payload";
export { resolveImportCapabilities, type TImportCapabilities } from "./permissions";
export { persistSurvey } from "./persist-survey";

View File

@@ -0,0 +1,41 @@
import { getProject } from "@/lib/project/service";
import { type TExportedLanguage } from "../export-survey";
export interface TMappedLanguage {
languageId: string;
enabled: boolean;
default: boolean;
}
export const mapLanguages = async (
exportedLanguages: TExportedLanguage[],
projectId: string
): Promise<{ mapped: TMappedLanguage[]; skipped: string[] }> => {
if (!exportedLanguages || exportedLanguages.length === 0) {
return { mapped: [], skipped: [] };
}
const project = await getProject(projectId);
if (!project) {
return { mapped: [], skipped: ["Project not found"] };
}
const mappedLanguages: TMappedLanguage[] = [];
const skipped: string[] = [];
for (const exportedLang of exportedLanguages) {
const projectLanguage = project.languages.find((l) => l.code === exportedLang.code);
if (!projectLanguage) {
skipped.push(`Language ${exportedLang.code} not found in project`);
continue;
}
mappedLanguages.push({
languageId: projectLanguage.id,
enabled: exportedLang.enabled,
default: exportedLang.default,
});
}
return { mapped: mappedLanguages, skipped };
};

View File

@@ -0,0 +1,55 @@
import { TActionClassInput } from "@formbricks/types/action-classes";
import { createActionClass } from "@/modules/survey/editor/lib/action-class";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { type TExportedTrigger } from "../export-survey";
export interface TMappedTrigger {
actionClass: { id: string };
}
export const mapTriggers = async (
importedTriggers: TExportedTrigger[],
environmentId: string
): Promise<{ mapped: TMappedTrigger[]; skipped: string[] }> => {
if (!importedTriggers || importedTriggers.length === 0) {
return { mapped: [], skipped: [] };
}
const existingActionClasses = await getActionClasses(environmentId);
const mappedTriggers: TMappedTrigger[] = [];
const skipped: string[] = [];
for (const trigger of importedTriggers) {
const ac = trigger.actionClass;
let existing = existingActionClasses.find((e) => e.key === ac.key && e.type === ac.type);
if (!existing) {
try {
const actionClassInput: TActionClassInput = {
environmentId,
name: `${ac.name} (imported)`,
description: ac.description ?? null,
type: ac.type,
key: ac.key,
noCodeConfig: ac.noCodeConfig,
};
existing = await createActionClass(environmentId, actionClassInput);
} catch (error) {
existing = await getActionClasses(environmentId).then((classes) =>
classes.find((e) => e.key === ac.key && e.type === ac.type)
);
}
}
if (existing) {
mappedTriggers.push({
actionClass: { id: existing.id },
});
} else {
skipped.push(`Could not find or create action class: ${ac.name} (${ac.key ?? "no key"})`);
}
}
return { mapped: mappedTriggers, skipped };
};

View File

@@ -0,0 +1,60 @@
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { addMultiLanguageLabels } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { type TMappedLanguage } from "./map-languages";
import { type TImportCapabilities } from "./permissions";
export const stripUnavailableFeatures = (
survey: TSurveyCreateInput,
capabilities: TImportCapabilities
): TSurveyCreateInput => {
const cloned = structuredClone(survey);
if (!capabilities.hasMultiLanguage) {
cloned.languages = [];
}
if (!capabilities.hasFollowUps) {
cloned.followUps = [];
}
if (!capabilities.hasRecaptcha) {
cloned.recaptcha = null;
}
delete cloned.segment;
return cloned;
};
export interface TSurveyLanguageConnection {
create: { languageId: string; enabled: boolean; default: boolean }[];
}
export const normalizeLanguagesForCreation = (
languages: TMappedLanguage[]
): TSurveyLanguageConnection | undefined => {
if (!languages || languages.length === 0) {
return undefined;
}
return {
create: languages.map((lang) => ({
languageId: lang.languageId,
enabled: lang.enabled,
default: lang.default,
})),
};
};
export const addLanguageLabels = (
survey: TSurveyCreateInput,
languageCodes: string[]
): TSurveyCreateInput => {
if (!languageCodes || languageCodes.length === 0) {
return survey;
}
const cloned = structuredClone(survey);
return addMultiLanguageLabels(cloned, languageCodes);
};

View File

@@ -0,0 +1,98 @@
import { z } from "zod";
import { TSurveyCreateInput, ZSurveyCreateInput } from "@formbricks/types/surveys/types";
import {
SURVEY_EXPORT_VERSION,
type TExportedLanguage,
type TExportedTrigger,
ZExportedLanguage,
ZExportedTrigger,
ZSurveyExportPayload,
} from "../export-survey";
export interface TParsedPayload {
surveyInput: TSurveyCreateInput;
exportedLanguages: TExportedLanguage[];
triggers: TExportedTrigger[];
}
export interface TParseError {
error: string;
details?: string[];
}
export const parseSurveyPayload = (surveyData: unknown): TParsedPayload | TParseError => {
if (typeof surveyData !== "object" || surveyData === null) {
return { error: "Invalid survey data: expected an object" };
}
let actualSurveyData: Record<string, unknown>;
// Check if this is the new versioned format (with version, exportDate, and data wrapper)
const versionedFormatCheck = ZSurveyExportPayload.safeParse(surveyData);
if (versionedFormatCheck.success) {
// New format: extract the data from the wrapper
const { version, data } = versionedFormatCheck.data;
// Validate version (for future compatibility)
if (version !== SURVEY_EXPORT_VERSION) {
console.warn(
`Import: Survey export version ${version} differs from current version ${SURVEY_EXPORT_VERSION}`
);
}
actualSurveyData = data as Record<string, unknown>;
} else {
// Legacy format or pre-versioning format: use data as-is
actualSurveyData = surveyData as Record<string, unknown>;
}
const surveyDataCopy = { ...actualSurveyData } as Record<string, unknown>;
// Validate and extract languages
const languagesResult = z.array(ZExportedLanguage).safeParse(surveyDataCopy.languages ?? []);
if (!languagesResult.success) {
return {
error: "Invalid languages format",
details: languagesResult.error.errors.map((e) => {
const path = e.path.length > 0 ? `languages.${e.path.join(".")}` : "languages";
return `Field "${path}": ${e.message}`;
}),
};
}
const exportedLanguages = languagesResult.data;
// Validate and extract triggers
const triggersResult = z.array(ZExportedTrigger).safeParse(surveyDataCopy.triggers ?? []);
if (!triggersResult.success) {
return {
error: "Invalid triggers format",
details: triggersResult.error.errors.map((e) => {
const path = e.path.length > 0 ? `triggers.${e.path.join(".")}` : "triggers";
return `Field "${path}": ${e.message}`;
}),
};
}
const triggers = triggersResult.data;
// Remove these from the copy before validating against ZSurveyCreateInput
delete surveyDataCopy.languages;
delete surveyDataCopy.triggers;
// Validate the main survey structure
const surveyResult = ZSurveyCreateInput.safeParse(surveyDataCopy);
if (!surveyResult.success) {
return {
error: "Invalid survey format",
details: surveyResult.error.errors.map((e) => {
const path = e.path.length > 0 ? e.path.join(".") : "root";
return `Field "${path}": ${e.message}`;
}),
};
}
return {
surveyInput: surveyResult.data,
exportedLanguages,
triggers,
};
};

View File

@@ -0,0 +1,34 @@
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
export interface TImportCapabilities {
hasMultiLanguage: boolean;
hasFollowUps: boolean;
hasRecaptcha: boolean;
}
export const resolveImportCapabilities = async (organizationId: string): Promise<TImportCapabilities> => {
let hasMultiLanguage = false;
try {
await checkMultiLanguagePermission(organizationId);
hasMultiLanguage = true;
} catch (e) {}
let hasFollowUps = false;
try {
const organizationBilling = await getOrganizationBilling(organizationId);
if (organizationBilling) {
hasFollowUps = await getSurveyFollowUpsPermission(organizationBilling.plan);
}
} catch (e) {}
let hasRecaptcha = false;
try {
await checkSpamProtectionPermission(organizationId);
hasRecaptcha = true;
} catch (e) {}
return { hasMultiLanguage, hasFollowUps, hasRecaptcha };
};

View File

@@ -0,0 +1,34 @@
import { createId } from "@paralleldrive/cuid2";
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { createSurvey } from "@/modules/survey/components/template-list/lib/survey";
import { type TMappedTrigger } from "./map-triggers";
import { type TSurveyLanguageConnection } from "./normalize-survey";
export const persistSurvey = async (
environmentId: string,
survey: TSurveyCreateInput,
newName: string,
createdBy: string,
mappedTriggers: TMappedTrigger[],
mappedLanguages?: TSurveyLanguageConnection
): Promise<{ surveyId: string }> => {
const followUpsWithNewIds = survey.followUps?.map((f) => ({
...f,
id: createId(),
surveyId: createId(),
}));
const surveyToCreate = {
...survey,
name: newName,
status: "draft" as const,
triggers: mappedTriggers as any, // Type system expects full ActionClass, but createSurvey only uses the id
followUps: followUpsWithNewIds,
createdBy,
...(mappedLanguages && { languages: mappedLanguages as any }), // Prisma nested create format
} as TSurveyCreateInput;
const newSurvey = await createSurvey(environmentId, surveyToCreate);
return { surveyId: newSurvey.id };
};

View File

@@ -8,6 +8,7 @@ import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
import { ImportSurveyButton } from "@/modules/survey/list/components/import-survey-button";
import { SurveysList } from "@/modules/survey/list/components/survey-list";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { TemplateContainerWithPreview } from "@/modules/survey/templates/components/template-container";
@@ -46,14 +47,17 @@ export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps)
const currentProjectChannel = project.config.channel ?? null;
const locale = (await getUserLocale(session.user.id)) ?? DEFAULT_LOCALE;
const CreateSurveyButton = () => {
const SurveyListCTA = () => {
return (
<Button size="sm" asChild>
<Link href={`/environments/${environment.id}/surveys/templates`}>
{t("environments.surveys.new_survey")}
<PlusIcon />
</Link>
</Button>
<div className="flex gap-2">
<ImportSurveyButton environmentId={environment.id} />
<Button size="sm" asChild>
<Link href={`/environments/${environment.id}/surveys/templates`}>
{t("environments.surveys.new_survey")}
<PlusIcon />
</Link>
</Button>
</div>
);
};
@@ -77,7 +81,7 @@ export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps)
if (surveyCount > 0) {
content = (
<>
<PageHeader pageTitle={t("common.surveys")} cta={isReadOnly ? <></> : <CreateSurveyButton />} />
<PageHeader pageTitle={t("common.surveys")} cta={isReadOnly ? <></> : <SurveyListCTA />} />
<SurveysList
environmentId={environment.id}
isReadOnly={isReadOnly}

View File

@@ -1,7 +1,6 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { Project } from "@prisma/client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { CheckIcon } from "lucide-react";
import React from "react";
@@ -11,7 +10,6 @@ import { TProjectStyling } from "@formbricks/types/project";
import { TSurveyStyling, TSurveyType } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
import { Badge } from "@/modules/ui/components/badge";
import { CardArrangementTabs } from "@/modules/ui/components/card-arrangement-tabs";
import { ColorPicker } from "@/modules/ui/components/color-picker";
import { FormControl, FormDescription, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
@@ -24,7 +22,6 @@ type CardStylingSettingsProps = {
isSettingsPage?: boolean;
surveyType?: TSurveyType;
disabled?: boolean;
project: Project;
form: UseFormReturn<TProjectStyling | TSurveyStyling>;
};
@@ -33,14 +30,12 @@ export const CardStylingSettings = ({
surveyType,
disabled,
open,
project,
setOpen,
form,
}: CardStylingSettingsProps) => {
const { t } = useTranslation();
const isAppSurvey = surveyType === "app";
const surveyTypeDerived = isAppSurvey ? "App" : "Link";
const isLogoVisible = !!project.logo?.url;
const linkCardArrangement = form.watch("cardArrangement.linkSurveys") ?? "straight";
const appCardArrangement = form.watch("cardArrangement.appSurveys") ?? "straight";
@@ -222,35 +217,6 @@ export const CardStylingSettings = ({
/>
</div>
{isLogoVisible && (!surveyType || surveyType === "link") && !isSettingsPage && (
<div className="flex items-center space-x-1">
<FormField
control={form.control}
name="isLogoHidden"
render={({ field }) => (
<FormItem className="flex w-full items-center gap-2 space-y-0">
<FormControl>
<Switch
id="isLogoHidden"
checked={!!field.value}
onCheckedChange={(checked) => field.onChange(checked)}
/>
</FormControl>
<div>
<FormLabel>
{t("environments.surveys.edit.hide_logo")}
<Badge type="gray" size="normal" text={t("common.link_surveys")} />
</FormLabel>
<FormDescription>
{t("environments.surveys.edit.hide_the_logo_in_this_specific_survey")}
</FormDescription>
</div>
</FormItem>
)}
/>
</div>
)}
{(!surveyType || isAppSurvey) && (
<div className="flex max-w-xs flex-col gap-4">
<div className="flex items-center space-x-1">

View File

@@ -5,20 +5,29 @@ import { ArrowUpRight } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TLogo } from "@formbricks/types/styling";
import { cn } from "@/lib/cn";
interface ClientLogoProps {
environmentId?: string;
projectLogo: Project["logo"] | null;
surveyLogo?: TLogo | null;
previewSurvey?: boolean;
}
export const ClientLogo = ({ environmentId, projectLogo, previewSurvey = false }: ClientLogoProps) => {
export const ClientLogo = ({
environmentId,
projectLogo,
surveyLogo,
previewSurvey = false,
}: ClientLogoProps) => {
const { t } = useTranslation();
const logoToUse = surveyLogo?.url ? surveyLogo : projectLogo;
return (
<div
className={cn(previewSurvey ? "" : "left-3 top-3 md:left-7 md:top-7", "group absolute z-0 rounded-lg")}
style={{ backgroundColor: projectLogo?.bgColor }}>
style={{ backgroundColor: logoToUse?.bgColor }}>
{previewSurvey && environmentId && (
<Link
href={`/environments/${environmentId}/project/look`}
@@ -30,9 +39,9 @@ export const ClientLogo = ({ environmentId, projectLogo, previewSurvey = false }
/>
</Link>
)}
{projectLogo?.url ? (
{logoToUse?.url ? (
<Image
src={projectLogo?.url}
src={logoToUse?.url}
className={cn(
previewSurvey ? "max-h-12" : "max-h-16 md:max-h-20",
"w-auto max-w-40 object-contain p-1 md:max-w-56"

View File

@@ -117,7 +117,7 @@ export const ConfirmationModal = ({
<CircleAlert className="h-4 w-4 text-slate-500" />
)}
<div className="flex flex-col">
<DialogTitle className="w-full text-left">{title}</DialogTitle>
<DialogTitle className="w-full truncate text-left">{title}</DialogTitle>
<DialogDescription className="w-full text-left">
<span className="mt-2 whitespace-pre-wrap">
{description ?? t("environments.project.general.this_action_cannot_be_undone")}

View File

@@ -263,7 +263,12 @@ export const PreviewSurvey = ({
<div className="flex h-full w-full flex-col justify-center px-1">
<div className="absolute left-5 top-5">
{!styling.isLogoHidden && (
<ClientLogo environmentId={environment.id} projectLogo={project.logo} previewSurvey />
<ClientLogo
environmentId={environment.id}
projectLogo={project.logo}
surveyLogo={styling.logo}
previewSurvey
/>
)}
</div>
<div className="z-10 w-full rounded-lg border border-transparent">
@@ -363,7 +368,12 @@ export const PreviewSurvey = ({
isEditorView>
<div className="absolute left-5 top-5">
{!styling.isLogoHidden && (
<ClientLogo environmentId={environment.id} projectLogo={project.logo} previewSurvey />
<ClientLogo
environmentId={environment.id}
projectLogo={project.logo}
surveyLogo={styling.logo}
previewSurvey
/>
)}
</div>
<div className="z-0 w-full max-w-4xl rounded-lg border-transparent">

View File

@@ -15,7 +15,8 @@
"test:coverage": "dotenv -e ../../.env -- vitest run --coverage",
"generate-api-specs": "./scripts/openapi/generate.sh",
"merge-client-endpoints": "tsx ./scripts/openapi/merge-client-endpoints.ts",
"generate-and-merge-api-specs": "pnpm run generate-api-specs && pnpm run merge-client-endpoints"
"generate-and-merge-api-specs": "pnpm run generate-api-specs && pnpm run merge-client-endpoints",
"i18n:generate": "npx lingo.dev@latest i18n"
},
"dependencies": {
"@aws-sdk/client-s3": "3.879.0",
@@ -101,11 +102,11 @@
"lucide-react": "0.507.0",
"markdown-it": "14.1.0",
"mime-types": "3.0.1",
"next": "15.5.6",
"next": "15.5.7",
"next-auth": "4.24.12",
"next-safe-action": "7.10.8",
"node-fetch": "3.3.2",
"nodemailer": "7.0.9",
"nodemailer": "7.0.11",
"otplib": "12.0.1",
"papaparse": "5.5.2",
"prismjs": "1.30.0",

View File

@@ -62,7 +62,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
await expect(page.getByPlaceholder(surveys.createAndSubmit.openTextQuestion.placeholder)).toBeVisible();
await page
.getByPlaceholder(surveys.createAndSubmit.openTextQuestion.placeholder)
.fill("This is my Open Text answer");
.fill("Open Text answer");
await page.locator("#questionCard-0").getByRole("button", { name: "Next" }).click();
// Single Select Question
@@ -116,7 +116,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5);
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Back" })).toBeVisible();
await page.locator("path").nth(3).click();
await page.getByRole("radio", { name: "Rate 3 out of" }).check();
await page.locator("#questionCard-3").getByRole("button", { name: "Next" }).click();
// NPS Question
@@ -212,11 +212,9 @@ test.describe("Survey Create & Submit Response without logic", async () => {
// Address Question
await expect(page.getByText(surveys.createAndSubmit.address.question)).toBeVisible();
await expect(page.getByLabel(surveys.createAndSubmit.address.placeholder.addressLine1)).toBeVisible();
await page
.getByLabel(surveys.createAndSubmit.address.placeholder.addressLine1)
.fill("This is my Address");
await page.getByLabel(surveys.createAndSubmit.address.placeholder.addressLine1).fill("Address");
await expect(page.getByLabel(surveys.createAndSubmit.address.placeholder.city)).toBeVisible();
await page.getByLabel(surveys.createAndSubmit.address.placeholder.city).fill("This is my city");
await page.getByLabel(surveys.createAndSubmit.address.placeholder.city).fill("city");
await expect(page.getByLabel(surveys.createAndSubmit.address.placeholder.zip)).toBeVisible();
await page.getByLabel(surveys.createAndSubmit.address.placeholder.zip).fill("12345");
await page.locator("#questionCard-10").getByRole("button", { name: "Next" }).click();
@@ -232,7 +230,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
for (let i = 0; i < surveys.createAndSubmit.ranking.choices.length; i++) {
await page.getByText(surveys.createAndSubmit.ranking.choices[i]).click();
}
await page.locator("#questionCard-12").getByRole("button", { name: "Next" }).click();
await page.locator("#questionCard-12").getByRole("button", { name: "Finish" }).click();
// loading spinner -> wait for it to disappear
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
});
@@ -785,7 +783,7 @@ test.describe("Testing Survey with advanced logic", async () => {
).toBeVisible();
await page
.getByPlaceholder(surveys.createWithLogicAndSubmit.openTextQuestion.placeholder)
.fill("This is my Open Text answer");
.fill("Open Text answer");
await page.locator("#questionCard-0").getByRole("button", { name: "Next" }).click();
// Single Select Question
@@ -858,10 +856,9 @@ test.describe("Testing Survey with advanced logic", async () => {
await expect(
page.locator("#questionCard-4").getByText(surveys.createWithLogicAndSubmit.ratingQuestion.highLabel)
).toBeVisible();
expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5);
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Back" })).toBeVisible();
await page.getByRole("group", { name: "Choices" }).locator("path").nth(3).click();
await page.getByRole("radio", { name: "Rate 4 out of" }).check();
await page.locator("#questionCard-4").getByRole("button", { name: "Next" }).click();
// NPS Question
@@ -972,14 +969,12 @@ test.describe("Testing Survey with advanced logic", async () => {
).toBeVisible();
await page
.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.addressLine1)
.fill("This is my Address");
.fill("Address");
await expect(page.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.city)).toBeVisible();
await page
.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.city)
.fill("This is my city");
await page.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.city).fill("city");
await expect(page.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.zip)).toBeVisible();
await page.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.zip).fill("12345");
await page.locator("#questionCard-13").getByRole("button", { name: "Next" }).click();
await page.locator("#questionCard-13").getByRole("button", { name: "Finish" }).click();
// loading spinner -> wait for it to disappear
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
@@ -997,13 +992,26 @@ test.describe("Testing Survey with advanced logic", async () => {
const updatedUrl = currentUrl.replace("summary?share=true", "responses");
await page.goto(updatedUrl);
await page.waitForSelector("#response-table");
await page.waitForSelector("table#response-table");
await expect(page.getByRole("cell", { name: "score" })).toBeVisible();
await page.waitForLoadState("networkidle");
await page.waitForTimeout(5000);
await expect(page.getByRole("cell", { name: "32", exact: true })).toBeVisible();
await page.pause();
// Look for any cell containing "32" or a score-related value
const scoreCell = page.getByRole("cell").filter({ hasText: /^32/ });
await expect(scoreCell).toBeVisible({
timeout: 15000,
});
// Look for the secret message in the table
const secretCell = page.getByRole("cell").filter({ hasText: /This is a secret message for e2e tests/ });
await expect(secretCell).toBeVisible({
timeout: 15000,
});
});
});
});

View File

@@ -656,7 +656,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
await page.locator("#action-2-operator").click();
await page.getByRole("option", { name: "Assign =" }).click();
await page.locator("#action-2-value-input").click();
await page.locator("#action-2-value-input").fill("1");
await page.locator("#action-2-value-input").fill("This ");
// Close Block 1 settings before moving to Block 2
await page
.locator("div")

View File

@@ -106,51 +106,51 @@ export const surveys = {
createAndSubmit: {
welcomeCard: {
headline: "Welcome to My Testing Survey Welcome Card!",
description: "This is the description of my Welcome Card!",
description: "the description of my Welcome Card!",
},
openTextQuestion: {
question: "This is my Open Text Question",
description: "This is my Open Text Description",
placeholder: "This is my Placeholder",
question: "Open Text Question",
description: "Open Text Description",
placeholder: "Placeholder",
},
singleSelectQuestion: {
question: "This is my Single Select Question",
description: "This is my Single Select Description",
question: "Single Select Question",
description: "Single Select Description",
options: ["Option 1", "Option 2"],
},
multiSelectQuestion: {
question: "This is my Multi Select Question",
description: "This is Multi Select Description",
question: "Multi Select Question",
description: "Multi Select Description",
options: ["Option 1", "Option 2", "Option 3"],
},
ratingQuestion: {
question: "This is my Rating Question",
description: "This is Rating Description",
question: "Rating Question",
description: "Rating Description",
lowLabel: "My Lower Label",
highLabel: "My Upper Label",
},
npsQuestion: {
question: "This is my NPS Question",
question: "NPS Question",
lowLabel: "My Lower Label",
highLabel: "My Upper Label",
},
ctaQuestion: {
question: "This is my CTA Question",
question: "CTA Question",
buttonLabel: "My Button Label",
},
consentQuestion: {
question: "This is my Consent Question",
question: "Consent Question",
checkboxLabel: "My Checkbox Label",
},
pictureSelectQuestion: {
question: "This is my Picture Select Question",
description: "This is Picture Select Description",
question: "Picture Select Question",
description: "Picture Select Description",
},
dateQuestion: {
question: "This is my Date Question",
question: "Date Question",
},
fileUploadQuestion: {
question: "This is my File Upload Question",
question: "File Upload Question",
},
matrix: {
question: "How much do you love these flowers?",
@@ -178,57 +178,57 @@ export const surveys = {
createWithLogicAndSubmit: {
welcomeCard: {
headline: "Welcome to My Testing Survey Welcome Card!",
description: "This is the description of my Welcome Card!",
description: "the description of my Welcome Card!",
},
openTextQuestion: {
question: "This is my Open Text Question",
description: "This is my Open Text Description",
placeholder: "This is my Placeholder",
question: "Open Text Question",
description: "Open Text Description",
placeholder: "Placeholder",
},
singleSelectQuestion: {
question: "This is my Single Select Question",
description: "This is my Single Select Description",
question: "Single Select Question",
description: "Single Select Description",
options: ["Option 1", "Option 2"],
},
multiSelectQuestion: {
question: "This is my Multi Select Question",
description: "This is Multi Select Description",
question: "Multi Select Question",
description: "Multi Select Description",
options: ["Option 1", "Option 2", "Option 3"],
},
ratingQuestion: {
question: "This is my Rating Question",
description: "This is Rating Description",
question: "Rating Question",
description: "Rating Description",
lowLabel: "My Lower Label",
highLabel: "My Upper Label",
},
npsQuestion: {
question: "This is my NPS Question",
question: "NPS Question",
lowLabel: "My Lower Label",
highLabel: "My Upper Label",
},
ctaQuestion: {
question: "This is my CTA Question",
question: "CTA Question",
buttonLabel: "My Button Label",
},
consentQuestion: {
question: "This is my Consent Question",
question: "Consent Question",
checkboxLabel: "My Checkbox Label",
},
pictureSelectQuestion: {
question: "This is my Picture Select Question",
description: "This is Picture Select Description",
question: "Picture Select Question",
description: "Picture Select Description",
},
fileUploadQuestion: {
question: "This is my File Upload Question",
question: "File Upload Question",
},
date: {
question: "This is my Date Question",
question: "Date Question",
},
cal: {
question: "This is my cal Question",
question: "cal Question",
},
matrix: {
question: "This is my Matrix Question",
question: "Matrix Question",
description: "0: Not at all, 3: Love it",
rows: ["Roses", "Trees", "Ocean"],
columns: ["0", "1", "2", "3"],
@@ -242,7 +242,7 @@ export const surveys = {
},
},
ranking: {
question: "This is my Ranking Question",
question: "Ranking Question",
choices: ["Work", "Money", "Travel", "Family", "Friends"],
},
endingCard: {
@@ -342,12 +342,12 @@ export const actions = {
noCode: {
click: {
name: "Create Click Action (CSS Selector)",
description: "This is my Create Action (click, CSS Selector)",
description: "Create Action (click, CSS Selector)",
selector: ".my-custom-class",
},
pageView: {
name: "Create Page view Action (specific Page URL)",
description: "This is my Create Action (Page view)",
description: "Create Action (Page view)",
matcher: {
label: "Contains",
value: "custom-url",
@@ -355,16 +355,16 @@ export const actions = {
},
exitIntent: {
name: "Create Exit Intent Action",
description: "This is my Create Action (Exit Intent)",
description: "Create Action (Exit Intent)",
},
fiftyPercentScroll: {
name: "Create 50% Scroll Action",
description: "This is my Create Action (50% Scroll)",
description: "Create Action (50% Scroll)",
},
},
code: {
name: "Create Action (Code)",
description: "This is my Create Action (Code)",
description: "Create Action (Code)",
key: "Create Action (Code)",
},
},
@@ -372,12 +372,12 @@ export const actions = {
noCode: {
click: {
name: "Edit Click Action (CSS Selector)",
description: "This is my Edit Action (click, CSS Selector)",
description: "Edit Action (click, CSS Selector)",
selector: ".my-custom-class-edited",
},
pageView: {
name: "Edit Page view Action (specific Page URL)",
description: "This is my Edit Action (Page view)",
description: "Edit Action (Page view)",
matcher: {
label: "Starts with",
value: "custom-url0-edited",
@@ -386,26 +386,26 @@ export const actions = {
},
exitIntent: {
name: "Edit Exit Intent Action",
description: "This is my Edit Action (Exit Intent)",
description: "Edit Action (Exit Intent)",
},
fiftyPercentScroll: {
name: "Edit 50% Scroll Action",
description: "This is my Edit Action (50% Scroll)",
description: "Edit Action (50% Scroll)",
},
},
code: {
description: "This is my Edit Action (Code)",
description: "Edit Action (Code)",
},
},
delete: {
noCode: {
name: "Delete click Action (CSS Selector)",
description: "This is my Delete Action (CSS Selector)",
description: "Delete Action (CSS Selector)",
selector: ".my-custom-class-deleted",
},
code: {
name: "Delete Action (Code)",
description: "This is my Delete Action (Code)",
description: "Delete Action (Code)",
},
},
};

View File

@@ -234,6 +234,7 @@
"self-hosting/configuration/smtp",
"self-hosting/configuration/file-uploads",
"self-hosting/configuration/domain-configuration",
"self-hosting/configuration/custom-subpath",
{
"group": "Auth & SSO",
"icon": "lock",

View File

@@ -0,0 +1,90 @@
---
title: "Custom Subpath"
description: "Serve Formbricks from a custom URL prefix when you cannot expose it on the root domain."
icon: "link"
---
<Note>
Custom subpath deployments are currently under internal review. If you need early access, please reach out via
[GitHub Discussions](https://github.com/formbricks/formbricks/discussions).
</Note>
### When to use a custom subpath
Use a custom subpath (also called a Next.js base path) when your reverse proxy reserves the root domain for another
service, but you still want Formbricks to live under the same hostname—for example `https://example.com/feedback`.
Support for a build-time `BASE_PATH` variable is available in the Formbricks web app so that all internal routes,
assets, and sign-in redirects honor the prefix.
### Requirements and limitations
- `BASE_PATH` must be present during `pnpm build`; changing it afterward requires a rebuild.
- Official Formbricks Docker images do **not** accept this flag for technical reasons, so you must build your own image.
- All public URLs (`WEBAPP_URL`, `NEXTAUTH_URL`, webhook targets, OAuth callbacks, etc.) need the same prefix.
- Your proxy must rewrite `/custom-path/*` to the Formbricks container while keeping the prefix visible to clients.
### Configure environment variables
Add the following variables to the environment you use for builds (local, CI, or Docker build args):
```bash
BASE_PATH="/custom-path"
WEBAPP_URL="https://yourdomain.com/custom-path"
NEXTAUTH_URL="https://yourdomain.com/custom-path/api/auth"
```
If you use email links, webhooks, or third-party OAuth providers, ensure every URL you register includes the prefix.
### Build a Docker image with a custom subpath
<Steps>
<Step title="Clone Formbricks and prepare secrets">
Make sure you have the repository checked out and create temporary files (or use <code>--secret</code>) for the
required build-time secrets such as <code>DATABASE_URL</code>, <code>ENCRYPTION_KEY</code>, <code>REDIS_URL</code>,
and optional telemetry tokens.
</Step>
<Step title="Pass BASE_PATH as a build argument">
Use the Formbricks web Dockerfile and supply the custom subpath via <code>--build-arg</code>. Example:
```bash
docker build \
--progress=plain \
--no-cache \
--build-arg BASE_PATH=/custom-path \
--secret id=database_url,src=<(printf "postgresql://user:password@localhost:5432/formbricks?schema=public") \
--secret id=encryption_key,src=<(printf "your-32-character-encryption-key-here") \
--secret id=redis_url,src=<(printf "redis://localhost:6379") \
--secret id=sentry_auth_token,src=<(printf "") \
-f apps/web/Dockerfile \
-t formbricks-web \
.
```
During the build logs you should see <code>BASE PATH /custom-path</code>, confirming that Next.js picked up the
prefix.
</Step>
<Step title="Run the container behind your proxy">
Start the resulting image with the same runtime environment variables you normally use (database credentials,
mailing provider, etc.). Point your reverse proxy so that <code>/custom-path</code> requests forward to
<code>http://formbricks-web:3000/custom-path</code> without stripping the prefix.
</Step>
</Steps>
### Verify the deployment
1. Open `https://yourdomain.com/custom-path` and complete the onboarding flow.
2. Create a survey and preview it—embedded scripts now load assets relative to the subpath.
3. Sign out and confirm the login page still includes `/custom-path`.
### Troubleshooting checklist
- Confirm your build pipeline actually passes `BASE_PATH` (and, if needed, `WEBAPP_URL`/`NEXTAUTH_URL`) into the build
stage—check CI logs for the `BASE PATH /your-prefix` line and make sure custom Dockerfiles or wrappers forward
`--build-arg BASE_PATH=...` correctly.
- If you cannot log in, double-check that `NEXTAUTH_URL` includes the prefix and uses the full route to the API as stated above. NextAuth rejects callbacks that do not
match exactly.
- Re-run the Docker build when changing `BASE_PATH`; simply editing the container environment is not sufficient.
- Inspect your proxy configuration to ensure it does not rewrite paths internally (e.g., `strip_prefix` needs to stay
disabled).
- When in doubt, rebuild locally with `--progress=plain` and verify that the `BASE PATH` line reflects your prefix.

View File

@@ -81,6 +81,13 @@ Example of Response Created webhook payload:
}
},
"singleUseId": null,
"survey": {
"title": "Customer Satisfaction Survey",
"type": "link",
"status": "inProgress",
"createdAt": "2025-07-20T10:30:00.000Z",
"updatedAt": "2025-07-24T07:45:00.000Z"
},
"surveyId": "surveyId",
"tags": [],
"ttc": {
@@ -125,6 +132,13 @@ Example of Response Updated webhook payload:
}
},
"singleUseId": null,
"survey": {
"title": "Customer Satisfaction Survey",
"type": "link",
"status": "inProgress",
"createdAt": "2025-07-20T10:30:00.000Z",
"updatedAt": "2025-07-24T07:45:00.000Z"
},
"surveyId": "surveyId",
"tags": [],
"ttc": {
@@ -170,6 +184,13 @@ Example of Response Finished webhook payload:
}
},
"singleUseId": null,
"survey": {
"title": "Customer Satisfaction Survey",
"type": "link",
"status": "inProgress",
"createdAt": "2025-07-20T10:30:00.000Z",
"updatedAt": "2025-07-24T07:45:00.000Z"
},
"surveyId": "surveyId",
"tags": [],
"ttc": {

View File

@@ -34,15 +34,16 @@
"prepare": "husky install",
"storybook": "turbo run storybook",
"fb-migrate-dev": "pnpm --filter @formbricks/database create-migration && pnpm prisma generate",
"i18n:generate": " pnpm --filter @formbricks/surveys i18n:generate",
"generate-translations": "cd apps/web && npx lingo.dev@latest i18n",
"i18n:surveys:generate": "pnpm --filter @formbricks/surveys i18n:generate",
"i18n:web:generate": "pnpm --filter @formbricks/web i18n:generate",
"generate-translations": "pnpm i18n:web:generate && pnpm i18n:surveys:generate",
"scan-translations": "pnpm --filter @formbricks/i18n-utils scan-translations",
"i18n": "pnpm generate-translations && pnpm scan-translations",
"i18n:validate": "pnpm scan-translations"
},
"dependencies": {
"react": "19.1.0",
"react-dom": "19.1.0"
"react": "19.1.2",
"react-dom": "19.1.2"
},
"devDependencies": {
"@azure/microsoft-playwright-testing": "1.0.0-beta.7",
@@ -83,11 +84,12 @@
},
"overrides": {
"axios": ">=1.12.2",
"node-forge": ">=1.3.2",
"tar-fs": "2.1.4",
"typeorm": ">=0.3.26"
},
"comments": {
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: axios (CVE-2025-58754) - awaiting @boxyhq/saml-jackson update | tar-fs (Dependabot #205) - awaiting upstream dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update"
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: axios (CVE-2025-58754) - awaiting @boxyhq/saml-jackson update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | tar-fs (Dependabot #205) - awaiting upstream dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update"
}
}
}

View File

@@ -2,6 +2,8 @@
import { SurveyStatus, SurveyType } from "@prisma/client";
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
// eslint-disable-next-line import/no-relative-packages -- Need to import from parent package
import { ZLogo } from "../../types/styling";
import { ZSurveyBlocks } from "../../types/surveys/blocks";
import {
ZSurveyEnding,
@@ -172,6 +174,7 @@ const ZSurveyBase = z.object({
background: ZSurveyStylingBackground.nullish(),
hideProgressBar: z.boolean().nullish(),
isLogoHidden: z.boolean().nullish(),
logo: ZLogo.nullish(),
})
.nullable()
.openapi({

View File

@@ -43,8 +43,6 @@ export function AddressElement({
return Array.isArray(value) ? value : ["", "", "", "", "", ""];
}, [value]);
const isCurrent = element.id === currentElementId;
const fields = useMemo(
() => [
{
@@ -166,7 +164,7 @@ export function AddressElement({
handleChange(field.id, e.currentTarget.value);
}}
ref={index === 0 ? addressRef : null}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
aria-label={field.label}
dir={!safeValue[index] ? dir : "auto"}
/>

View File

@@ -32,7 +32,6 @@ export function ConsentElement({
}: Readonly<ConsentElementProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = element.imageUrl || element.videoUrl;
const isCurrent = element.id === currentElementId;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
@@ -66,7 +65,7 @@ export function ConsentElement({
/>
<label
ref={consentRef}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0} // NOSONAR - needed for keyboard navigation through options
id={`${element.id}-label`}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input

View File

@@ -37,7 +37,6 @@ export function ContactInfoElement({
const isMediaAvailable = element.imageUrl || element.videoUrl;
const formRef = useRef<HTMLFormElement>(null);
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const isCurrent = element.id === currentElementId;
const safeValue = useMemo(() => {
return Array.isArray(value) ? value : ["", "", "", "", ""];
}, [value]);
@@ -149,7 +148,7 @@ export function ContactInfoElement({
onChange={(e) => {
handleChange(field.id, e.currentTarget.value);
}}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
aria-label={field.label}
dir={!safeValue[index] ? dir : "auto"}
/>

View File

@@ -67,7 +67,7 @@ export function CTAElement({
<button
dir="auto"
type="button"
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
onClick={handleExternalButtonClick}
className="fb-text-heading focus:fb-ring-focus fb-flex fb-items-center fb-rounded-md fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 hover:fb-opacity-90 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2">
{getLocalizedValue(element.ctaButtonLabel, languageCode)}

View File

@@ -86,7 +86,6 @@ export function DateElement({
const [errorMessage, setErrorMessage] = useState("");
const isMediaAvailable = element.imageUrl || element.videoUrl;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const isCurrent = element.id === currentElementId;
const [datePickerOpen, setDatePickerOpen] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(value ? new Date(value) : undefined);
const [hideInvalid, setHideInvalid] = useState(!selectedDate);
@@ -161,7 +160,7 @@ export function DateElement({
onClick={() => {
setDatePickerOpen(true);
}}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
type="button"
onKeyDown={(e) => {
if (e.key === " ") setDatePickerOpen(true);

View File

@@ -31,7 +31,6 @@ export function MatrixElement({
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = element.imageUrl || element.videoUrl;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const isCurrent = element.id === currentElementId;
const rowShuffleIdx = useMemo(() => {
if (element.shuffleOption !== "none") {
return getShuffledRowIndices(element.rows.length, element.shuffleOption);
@@ -127,7 +126,7 @@ export function MatrixElement({
{element.columns.map((column, columnIndex) => (
<td
key={`column-${columnIndex.toString()}`}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
className={`fb-outline-brand fb-px-4 fb-py-2 fb-text-slate-800 ${columnIndex === element.columns.length - 1 ? "fb-rounded-r-custom" : ""}`}
onClick={() => {
handleSelect(

View File

@@ -57,7 +57,6 @@ export function MultipleChoiceMultiElement({
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = element.imageUrl || element.videoUrl;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const isCurrent = element.id === currentElementId;
const shuffledChoicesIds = useMemo(() => {
if (element.shuffleOption) {
return getShuffledChoicesIds(element.choices, element.shuffleOption);
@@ -212,9 +211,9 @@ export function MultipleChoiceMultiElement({
return (
<label
key={choice.id}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0} // NOSONAR - needed for keyboard navigation through options
className={labelClassName}
onKeyDown={handleKeyDown(choice.id)}
onKeyDown={handleKeyDown(choice.id)} // NOSONAR - needed for keyboard navigation through options
autoFocus={idx === 0 && autoFocusEnabled}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
@@ -261,14 +260,16 @@ export function MultipleChoiceMultiElement({
return (
<label
tabIndex={isCurrent ? 0 : -1}
tabIndex={0} // NOSONAR - needed for keyboard navigation through options
className={labelClassName}
onKeyDown={handleKeyDown(otherOption.id)}>
// Disable keyboard navigation when 'other' option is selected to allow space key in input field
onKeyDown={otherSelected ? undefined : handleKeyDown(otherOption.id)} // NOSONAR - needed for keyboard navigation through options
>
<span className="fb-flex fb-items-center fb-text-sm">
<input
type="checkbox"
dir={dir}
tabIndex={isCurrent ? 0 : -1}
tabIndex={-1}
id={otherOption.id}
name={element.id}
value={otherLabel}
@@ -289,7 +290,7 @@ export function MultipleChoiceMultiElement({
id={`${otherOption.id}-specify`}
maxLength={250}
name={element.id}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
value={otherValue}
pattern=".*\S+.*"
onChange={(e) => setOtherValue(e.currentTarget.value)}
@@ -314,9 +315,10 @@ export function MultipleChoiceMultiElement({
return (
<label
tabIndex={isCurrent ? 0 : -1}
tabIndex={0} // NOSONAR - needed for keyboard navigation through options
className={labelClassName}
onKeyDown={handleKeyDown(noneOption.id)}>
onKeyDown={handleKeyDown(noneOption.id)} // NOSONAR - needed for keyboard navigation through options
>
<span className="fb-flex fb-items-center fb-text-sm">
<input
type="checkbox"

View File

@@ -36,7 +36,6 @@ export function MultipleChoiceSingleElement({
const otherSpecify = useRef<HTMLInputElement | null>(null);
const choicesContainerRef = useRef<HTMLDivElement | null>(null);
const isMediaAvailable = element.imageUrl || element.videoUrl;
const isCurrent = element.id === currentElementId;
const shuffledChoicesIds = useMemo(() => {
if (element.shuffleOption) {
return getShuffledChoicesIds(element.choices, element.shuffleOption);
@@ -158,9 +157,9 @@ export function MultipleChoiceSingleElement({
return (
<label
key={choice.id}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0} // NOSONAR - needed for keyboard navigation through options
className={labelClassName}
onKeyDown={handleKeyDown(choice.id)}
onKeyDown={handleKeyDown(choice.id)} // NOSONAR - needed for keyboard navigation through options
autoFocus={idx === 0 && autoFocusEnabled}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
@@ -197,7 +196,11 @@ export function MultipleChoiceSingleElement({
: "Please specify";
return (
<label tabIndex={isCurrent ? 0 : -1} className={labelClassName} onKeyDown={handleOtherKeyDown}>
<label
tabIndex={0} // NOSONAR - needed for keyboard navigation through options
className={labelClassName}
onKeyDown={handleOtherKeyDown} // NOSONAR - needed for keyboard navigation through options
>
<span className="fb-flex fb-items-center fb-text-sm">
<input
tabIndex={-1}
@@ -246,7 +249,7 @@ export function MultipleChoiceSingleElement({
return (
<label
tabIndex={isCurrent ? 0 : -1}
tabIndex={0} // NOSONAR - needed for keyboard navigation through options
className={labelClassName}
onKeyDown={handleKeyDown(noneOption.id)}>
<span className="fb-flex fb-items-center fb-text-sm">

View File

@@ -33,7 +33,6 @@ export function NPSElement({
const [startTime, setStartTime] = useState(performance.now());
const [hoveredNumber, setHoveredNumber] = useState(-1);
const isMediaAvailable = element.imageUrl || element.videoUrl;
const isCurrent = element.id === currentElementId;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const handleClick = (number: number) => {
@@ -74,7 +73,7 @@ export function NPSElement({
return (
<label
key={number}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0} // NOSONAR - needed for keyboard navigation through options
onMouseOver={() => {
setHoveredNumber(number);
}}

View File

@@ -169,7 +169,7 @@ export function OpenTextElement({
<input
ref={inputRef as RefObject<HTMLInputElement>}
autoFocus={isCurrent ? autoFocusEnabled : undefined}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
name={element.id}
id={element.id}
placeholder={getLocalizedValue(element.placeholder, languageCode)}
@@ -195,7 +195,7 @@ export function OpenTextElement({
rows={3}
autoFocus={isCurrent ? autoFocusEnabled : undefined}
name={element.id}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
aria-label="textarea"
id={element.id}
placeholder={getLocalizedValue(element.placeholder, languageCode, true)}

View File

@@ -148,7 +148,7 @@ export function PictureSelectionElement({
<div className="fb-relative" key={choice.id}>
<button
type="button"
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
onKeyDown={handleKeyDown}
onClick={() => handleChange(choice.id)}
className={getButtonClassName(choice.id)}>

View File

@@ -159,7 +159,7 @@ export function RankingElement({
)}>
<button
autoFocus={idx === 0 && autoFocusEnabled}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === " ") {
e.preventDefault();

View File

@@ -46,7 +46,6 @@ export function RatingElement({
const [hoveredNumber, setHoveredNumber] = useState(0);
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = element.imageUrl || element.videoUrl;
const isCurrent = element.id === currentElementId;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const handleSelect = (number: number) => {
@@ -55,23 +54,6 @@ export function RatingElement({
setTtc(updatedTtcObj);
};
function HiddenRadioInput({ number, id }: { number: number; id?: string }) {
return (
<input
type="radio"
id={id}
name="rating"
value={number}
className="fb-invisible fb-absolute fb-left-0 fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
onClick={() => {
handleSelect(number);
}}
required={element.required}
checked={value === number}
/>
);
}
useEffect(() => {
setHoveredNumber(0);
}, [element.id, setHoveredNumber]);
@@ -97,14 +79,6 @@ export function RatingElement({
setTtc(updatedTtcObj);
};
const handleKeyDown = (number: number) => (e: KeyboardEvent) => {
const isActivationKey = e.key === " " || e.key === "Enter";
if (isActivationKey) {
e.preventDefault();
handleSelect(number);
}
};
const handleMouseOver = (number: number) => () => {
setHoveredNumber(number);
};
@@ -160,10 +134,21 @@ export function RatingElement({
);
};
const getRatingInputId = (number: number) => `${element.id}-${number}`;
const handleKeyDown = (number: number) => (e: KeyboardEvent) => {
if (e.key === " ") {
e.preventDefault();
const inputId = getRatingInputId(number);
document.getElementById(inputId)?.click();
document.getElementById(inputId)?.focus();
}
};
const renderNumberScale = (number: number, totalLength: number) => {
return (
<label
tabIndex={isCurrent ? 0 : -1}
tabIndex={0} // NOSONAR - needed for keyboard navigation through options
onKeyDown={handleKeyDown(number)}
className={getNumberLabelClassName(number, totalLength)}>
{element.isColorCodingEnabled && (
@@ -171,7 +156,19 @@ export function RatingElement({
className={`fb-absolute fb-left-0 fb-top-0 fb-h-[6px] fb-w-full ${getRatingNumberOptionColor(element.range, number)}`}
/>
)}
<HiddenRadioInput number={number} id={number.toString()} />
<input
type="radio"
id={getRatingInputId(number)}
name="rating"
value={number}
className="fb-absolute fb-left-0 fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
onClick={() => {
handleSelect(number);
}}
required={element.required}
checked={value === number}
tabIndex={-1}
/>
{number}
</label>
);
@@ -180,12 +177,25 @@ export function RatingElement({
const renderStarScale = (number: number) => {
return (
<label
tabIndex={isCurrent ? 0 : -1}
aria-label={`Rate ${number} out of ${element.range}`}
tabIndex={0} // NOSONAR - needed for keyboard navigation through options
onKeyDown={handleKeyDown(number)}
className={getStarLabelClassName(number)}
onFocus={handleFocus(number)}
onBlur={handleBlur}>
<HiddenRadioInput number={number} id={number.toString()} />
<input
type="radio"
id={getRatingInputId(number)}
name="rating"
value={number}
className="fb-absolute fb-left-0 fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
onClick={() => {
handleSelect(number);
}}
required={element.required}
checked={value === number}
tabIndex={-1}
/>
<div className="fb-h-full fb-w-full fb-max-w-[74px] fb-object-contain">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path
@@ -201,12 +211,25 @@ export function RatingElement({
const renderSmileyScale = (number: number, idx: number) => {
return (
<label
tabIndex={isCurrent ? 0 : -1}
aria-label={`Rate ${number} out of ${element.range}`}
tabIndex={0} // NOSONAR - needed for keyboard navigation through options
className={getSmileyLabelClassName(number)}
onKeyDown={handleKeyDown(number)}
onFocus={handleFocus(number)}
onBlur={handleBlur}>
<HiddenRadioInput number={number} id={number.toString()} />
<input
type="radio"
id={getRatingInputId(number)}
name="rating"
value={number}
className="fb-absolute fb-left-0 fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
onClick={() => {
handleSelect(number);
}}
required={element.required}
checked={value === number}
tabIndex={-1}
/>
<div className="fb-h-full fb-w-full fb-max-w-[74px] fb-object-contain">
<RatingSmiley
active={value === number || hoveredNumber === number}
@@ -254,7 +277,7 @@ export function RatingElement({
renderRatingOption(number, i, a.length)
)}
</div>
<div className="fb-text-subheading fb-mt-4 fb-flex fb-justify-between fb-px-1.5 fb-text-xs fb-leading-6 fb-gap-8">
<div className="fb-text-subheading fb-mt-8 fb-flex fb-justify-between fb-px-1.5 fb-text-xs fb-leading-6 fb-gap-8">
<p className="fb-max-w-[50%]" dir="auto">
{getLocalizedValue(element.lowerLabel, languageCode)}
</p>

View File

@@ -1,7 +1,7 @@
import { z } from "zod";
import { ZColor, ZPlacement } from "./common";
import { ZEnvironment } from "./environment";
import { ZBaseStyling } from "./styling";
import { ZBaseStyling, ZLogo } from "./styling";
export const ZProjectStyling = ZBaseStyling.extend({
allowStyleOverwrite: z.boolean(),
@@ -46,11 +46,6 @@ export const ZLanguageUpdate = z.object({
});
export type TLanguageUpdate = z.infer<typeof ZLanguageUpdate>;
export const ZLogo = z.object({
url: z.string().optional(),
bgColor: z.string().optional(),
});
export type TLogo = z.infer<typeof ZLogo>;
export const ZProject = z.object({

View File

@@ -15,6 +15,12 @@ export const ZCardArrangement = z.object({
appSurveys: ZCardArrangementOptions,
});
export const ZLogo = z.object({
url: z.string().optional(),
bgColor: z.string().optional(),
});
export type TLogo = z.infer<typeof ZLogo>;
export const ZSurveyStylingBackground = z
.object({
bg: z.string().nullish(),
@@ -48,6 +54,7 @@ export const ZBaseStyling = z.object({
background: ZSurveyStylingBackground.nullish(),
hideProgressBar: z.boolean().nullish(),
isLogoHidden: z.boolean().nullish(),
logo: ZLogo.nullish(),
});
export type TBaseStyling = z.infer<typeof ZBaseStyling>;

1207
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff