Compare commits

...

8 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
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
19 changed files with 1296 additions and 19 deletions

View File

@@ -1365,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.",
@@ -1439,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",
@@ -1688,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

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

@@ -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 || styling.logo?.url) && <ClientLogo projectLogo={project.logo} surveyLogo={styling.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

@@ -15,7 +15,12 @@ interface ClientLogoProps {
previewSurvey?: boolean;
}
export const ClientLogo = ({ environmentId, projectLogo, surveyLogo, previewSurvey = false }: ClientLogoProps) => {
export const ClientLogo = ({
environmentId,
projectLogo,
surveyLogo,
previewSurvey = false,
}: ClientLogoProps) => {
const { t } = useTranslation();
const logoToUse = surveyLogo?.url ? surveyLogo : projectLogo;