feat: sharing modal anonymous links (#6224)

Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
This commit is contained in:
Anshuman Pandey
2025-07-15 13:33:10 +05:30
committed by GitHub
parent e5591686b4
commit acd508ba19
55 changed files with 2647 additions and 1081 deletions

View File

@@ -313,3 +313,42 @@ export const generatePersonalLinksAction = authenticatedActionClient
count: csvData.length,
};
});
const ZUpdateSingleUseLinksAction = z.object({
surveyId: ZId,
environmentId: ZId,
isSingleUse: z.boolean(),
isSingleUseEncryption: z.boolean(),
});
export const updateSingleUseLinksAction = authenticatedActionClient
.schema(ZUpdateSingleUseLinksAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
const updatedSurvey = await updateSurvey({
...survey,
singleUse: { enabled: parsedInput.isSingleUse, isEncrypted: parsedInput.isSingleUseEncryption },
});
return updatedSurvey;
});

View File

@@ -1,6 +1,5 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
@@ -118,13 +117,13 @@ export const SummaryMetadata = ({
)}
</span>
{!isLoading && (
<Button variant="secondary" className="h-6 w-6">
<div className="flex h-6 w-6 items-center justify-center rounded-md border border-slate-200 bg-slate-50 hover:bg-slate-100">
{showDropOffs ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
</Button>
</div>
)}
</div>
</div>

View File

@@ -147,7 +147,6 @@ export const SurveyAnalysisCTA = ({
<IconBar actions={iconActions} />
<Button
className="h-10"
onClick={() => {
setModalState((prev) => ({ ...prev, share: true }));
}}>

View File

@@ -211,7 +211,7 @@ describe("ShareEmbedSurvey", () => {
expect(mockSetOpen).toHaveBeenCalledWith(true);
});
test("correctly configures for 'link' survey type in embed view", () => {
test("correctly configures for 'anon-links' survey type in embed view", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockSurveyLink} modalView="share" />);
const embedViewProps = vi.mocked(mockShareViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string; icon: LucideIcon }[];
@@ -221,12 +221,12 @@ describe("ShareEmbedSurvey", () => {
expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeUndefined();
expect(embedViewProps.tabs.find((tab) => tab.id === "dynamic-popup")).toBeDefined();
expect(embedViewProps.tabs.find((tab) => tab.id === "website-embed")).toBeDefined();
expect(embedViewProps.tabs[0].id).toBe("link");
expect(embedViewProps.tabs[0].id).toBe("anon-links");
expect(embedViewProps.tabs[1].id).toBe("qr-code");
expect(embedViewProps.tabs[2].id).toBe("personal-links");
expect(embedViewProps.tabs[3].id).toBe("email");
expect(embedViewProps.tabs[4].id).toBe("website-embed");
expect(embedViewProps.activeId).toBe("link");
expect(embedViewProps.activeId).toBe("anon-links");
});
test("correctly configures for 'web' survey type in embed view", () => {
@@ -277,8 +277,8 @@ describe("ShareEmbedSurvey", () => {
let embedViewProps = vi.mocked(mockShareViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string }[];
};
let linkTab = embedViewProps.tabs.find((tab) => tab.id === "link");
expect(linkTab?.label).toBe("environments.surveys.summary.share_the_link");
let linkTab = embedViewProps.tabs.find((tab) => tab.id === "anon-links");
expect(linkTab?.label).toBe("environments.surveys.share.anonymous_links.nav_title");
cleanup();
vi.mocked(mockShareViewComponent).mockClear();
@@ -290,8 +290,8 @@ describe("ShareEmbedSurvey", () => {
embedViewProps = vi.mocked(mockShareViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string }[];
};
linkTab = embedViewProps.tabs.find((tab) => tab.id === "link");
expect(linkTab?.label).toBe("environments.surveys.summary.single_use_links");
linkTab = embedViewProps.tabs.find((tab) => tab.id === "anon-links");
expect(linkTab?.label).toBe("environments.surveys.share.anonymous_links.nav_title");
});
test("includes QR code tab for link surveys", () => {
@@ -336,7 +336,7 @@ describe("ShareEmbedSurvey", () => {
tabs: { id: string; label: string }[];
};
test("QR code tab appears after link tab in the tabs array", () => {
const linkTabIndex = embedViewProps.tabs.findIndex((tab) => tab.id === "link");
const linkTabIndex = embedViewProps.tabs.findIndex((tab) => tab.id === "anon-links");
const qrCodeTabIndex = embedViewProps.tabs.findIndex((tab) => tab.id === "qr-code");
expect(qrCodeTabIndex).toBe(linkTabIndex + 1);
});

View File

@@ -1,5 +1,6 @@
"use client";
import { ShareViewType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
import { getSurveyUrl } from "@/modules/analysis/utils";
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
@@ -14,7 +15,6 @@ import {
UserIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { logger } from "@formbricks/logger";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
@@ -23,16 +23,6 @@ import { SuccessView } from "./shareEmbedModal/success-view";
type ModalView = "start" | "share";
enum ShareViewType {
LINK = "link",
QR_CODE = "qr-code",
PERSONAL_LINKS = "personal-links",
EMAIL = "email",
WEBSITE_EMBED = "website-embed",
DYNAMIC_POPUP = "dynamic-popup",
APP = "app",
}
interface ShareSurveyModalProps {
survey: TSurvey;
publicDomain: string;
@@ -57,14 +47,13 @@ export const ShareSurveyModal = ({
isFormbricksCloud,
}: ShareSurveyModalProps) => {
const environmentId = survey.environmentId;
const isSingleUseLinkSurvey = survey.singleUse?.enabled ?? false;
const { email } = user;
const { t } = useTranslate();
const linkTabs: { id: ShareViewType; label: string; icon: React.ElementType }[] = useMemo(
() => [
{
id: ShareViewType.LINK,
label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`,
id: ShareViewType.ANON_LINKS,
label: t("environments.surveys.share.anonymous_links.nav_title"),
icon: LinkIcon,
},
{
@@ -74,53 +63,42 @@ export const ShareSurveyModal = ({
},
{
id: ShareViewType.PERSONAL_LINKS,
label: t("environments.surveys.summary.personal_links"),
label: t("environments.surveys.share.personal_links.nav_title"),
icon: UserIcon,
},
{
id: ShareViewType.EMAIL,
label: t("environments.surveys.summary.embed_in_an_email"),
label: t("environments.surveys.share.send_email.nav_title"),
icon: MailIcon,
},
{
id: ShareViewType.WEBSITE_EMBED,
label: t("environments.surveys.summary.embed_on_website"),
label: t("environments.surveys.share.embed_on_website.nav_title"),
icon: Code2Icon,
},
{
id: ShareViewType.DYNAMIC_POPUP,
label: t("environments.surveys.summary.dynamic_popup"),
label: t("environments.surveys.share.dynamic_popup.nav_title"),
icon: SquareStack,
},
],
[t, isSingleUseLinkSurvey]
[t]
);
const appTabs = [
{
id: ShareViewType.APP,
label: t("environments.surveys.summary.embed_in_app"),
label: t("environments.surveys.share.embed_on_website.embed_in_app"),
icon: SmartphoneIcon,
},
];
const [activeId, setActiveId] = useState(survey.type === "link" ? ShareViewType.LINK : ShareViewType.APP);
const [showView, setShowView] = useState<ModalView>(modalView);
const [surveyUrl, setSurveyUrl] = useState("");
const [activeId, setActiveId] = useState(
survey.type === "link" ? ShareViewType.ANON_LINKS : ShareViewType.APP
);
useEffect(() => {
const fetchSurveyUrl = async () => {
try {
const url = await getSurveyUrl(survey, publicDomain, "default");
setSurveyUrl(url);
} catch (error) {
logger.error("Failed to fetch survey URL:", error);
// Fallback to a default URL if fetching fails
setSurveyUrl(`${publicDomain}/s/${survey.id}`);
}
};
fetchSurveyUrl();
}, [survey, publicDomain]);
const [surveyUrl, setSurveyUrl] = useState(() => getSurveyUrl(survey, publicDomain, "default"));
const [showView, setShowView] = useState<ModalView>(modalView);
useEffect(() => {
if (open) {
@@ -129,7 +107,7 @@ export const ShareSurveyModal = ({
}, [open, modalView]);
const handleOpenChange = (open: boolean) => {
setActiveId(survey.type === "link" ? ShareViewType.LINK : ShareViewType.APP);
setActiveId(survey.type === "link" ? ShareViewType.ANON_LINKS : ShareViewType.APP);
setOpen(open);
if (!open) {
setShowView("start");
@@ -150,7 +128,11 @@ export const ShareSurveyModal = ({
<VisuallyHidden asChild>
<DialogTitle />
</VisuallyHidden>
<DialogContent className="w-full bg-white p-0 lg:h-[700px]" width="wide" aria-describedby={undefined}>
<DialogContent
className="w-full overflow-y-auto bg-white p-0 lg:h-[700px]"
width="wide"
aria-describedby={undefined}
unconstrained>
{showView === "start" ? (
<SuccessView
survey={survey}

View File

@@ -1,76 +0,0 @@
"use client";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { H4 } from "@/modules/ui/components/typography";
import { useTranslate } from "@tolgee/react";
import { ExternalLinkIcon } from "lucide-react";
import Link from "next/link";
interface DynamicPopupTabProps {
environmentId: string;
surveyId: string;
}
interface DocumentationButtonProps {
href: string;
title: string;
readDocsText: string;
}
const DocumentationButton = ({ href, title, readDocsText }: DocumentationButtonProps) => {
return (
<Button variant="outline" asChild>
<Link
href={href}
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center justify-between p-4">
<div className="flex items-center gap-3">
<ExternalLinkIcon className="h-4 w-4 flex-shrink-0" />
<span className="text-left text-sm">{title}</span>
</div>
<span>{readDocsText}</span>
</Link>
</Button>
);
};
export const DynamicPopupTab = ({ environmentId, surveyId }: DynamicPopupTabProps) => {
const { t } = useTranslate();
return (
<div className="flex h-full flex-col justify-between space-y-4">
<Alert variant="info" size="default">
<AlertTitle>{t("environments.surveys.summary.dynamic_popup.alert_title")}</AlertTitle>
<AlertDescription>
{t("environments.surveys.summary.dynamic_popup.alert_description")}
</AlertDescription>
<AlertButton asChild>
<Link href={`/environments/${environmentId}/surveys/${surveyId}/edit`}>
{t("environments.surveys.summary.dynamic_popup.alert_button")}
</Link>
</AlertButton>
</Alert>
<div className="flex w-full flex-col space-y-4">
<H4>{t("environments.surveys.summary.dynamic_popup.title")}</H4>
<DocumentationButton
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting"
title={t("environments.surveys.summary.dynamic_popup.attribute_based_targeting")}
readDocsText={t("environments.surveys.summary.dynamic_popup.read_documentation")}
/>
<DocumentationButton
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions"
title={t("environments.surveys.summary.dynamic_popup.code_no_code_triggers")}
readDocsText={t("environments.surveys.summary.dynamic_popup.read_documentation")}
/>
<DocumentationButton
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/recontact"
title={t("environments.surveys.summary.dynamic_popup.recontact_options")}
readDocsText={t("environments.surveys.summary.dynamic_popup.read_documentation")}
/>
</div>
</div>
);
};

View File

@@ -1,133 +0,0 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { CodeBlock } from "@/modules/ui/components/code-block";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { useTranslate } from "@tolgee/react";
import { Code2Icon, CopyIcon, MailIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { AuthenticationError } from "@formbricks/types/errors";
import { getEmailHtmlAction, sendEmbedSurveyPreviewEmailAction } from "../../actions";
interface EmailTabProps {
surveyId: string;
email: string;
}
export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
const [showEmbed, setShowEmbed] = useState(false);
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
const { t } = useTranslate();
const emailHtml = useMemo(() => {
if (!emailHtmlPreview) return "";
return emailHtmlPreview
.replaceAll("?preview=true&amp;", "?")
.replaceAll("?preview=true&;", "?")
.replaceAll("?preview=true", "");
}, [emailHtmlPreview]);
useEffect(() => {
const getData = async () => {
const emailHtml = await getEmailHtmlAction({ surveyId });
setEmailHtmlPreview(emailHtml?.data || "");
};
getData();
}, [surveyId]);
const sendPreviewEmail = async () => {
try {
const val = await sendEmbedSurveyPreviewEmailAction({ surveyId });
if (val?.data) {
toast.success(t("environments.surveys.summary.email_sent"));
} else {
const errorMessage = getFormattedErrorMessage(val);
toast.error(errorMessage);
}
} catch (err) {
if (err instanceof AuthenticationError) {
toast.error(t("common.not_authenticated"));
return;
}
toast.error(t("common.something_went_wrong_please_try_again"));
}
};
return (
<div className="flex flex-col gap-5">
<div className="flex items-center justify-end gap-4">
{showEmbed ? (
<Button
variant="secondary"
title="Embed survey in your website"
aria-label="Embed survey in your website"
onClick={() => {
toast.success(t("environments.surveys.summary.embed_code_copied_to_clipboard"));
navigator.clipboard.writeText(emailHtml);
}}
className="shrink-0">
{t("common.copy_code")}
<CopyIcon />
</Button>
) : (
<>
<Button
variant="secondary"
title="send preview email"
aria-label="send preview email"
onClick={() => sendPreviewEmail()}
className="shrink-0">
{t("environments.surveys.summary.send_preview")}
<MailIcon />
</Button>
</>
)}
<Button
title={t("environments.surveys.summary.view_embed_code_for_email")}
aria-label={t("environments.surveys.summary.view_embed_code_for_email")}
onClick={() => {
setShowEmbed(!showEmbed);
}}
className="shrink-0">
{showEmbed
? t("environments.surveys.summary.hide_embed_code")
: t("environments.surveys.summary.view_embed_code")}
<Code2Icon />
</Button>
</div>
{showEmbed ? (
<div className="prose prose-slate -mt-4 max-w-full">
<CodeBlock
customCodeClass="text-sm h-48 overflow-y-scroll"
language="html"
showCopyToClipboard={false}>
{emailHtml}
</CodeBlock>
</div>
) : (
<div className="mb-12 grow overflow-y-auto rounded-xl border border-slate-200 bg-white p-4">
<div className="mb-6 flex gap-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
</div>
<div>
<div className="mb-2 border-b border-slate-200 pb-2 text-sm">To : {email || "user@mail.com"}</div>
<div className="border-b border-slate-200 pb-2 text-sm">
Subject : {t("environments.surveys.summary.formbricks_email_survey_preview")}
</div>
<div className="p-4">
{emailHtml ? (
<div dangerouslySetInnerHTML={{ __html: emailHtmlPreview }}></div>
) : (
<LoadingSpinner />
)}
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -1,155 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { LinkTab } from "./LinkTab";
// Mock ShareSurveyLink
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
ShareSurveyLink: vi.fn(({ survey, surveyUrl, publicDomain, locale }) => (
<div data-testid="share-survey-link">
Mocked ShareSurveyLink
<span data-testid="survey-id">{survey.id}</span>
<span data-testid="survey-url">{surveyUrl}</span>
<span data-testid="public-domain">{publicDomain}</span>
<span data-testid="locale">{locale}</span>
</div>
)),
}));
// Mock useTranslate
const mockTranslate = vi.fn((key) => key);
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: mockTranslate,
}),
}));
// Mock next/link
vi.mock("next/link", () => ({
default: ({ href, children, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
),
}));
const mockSurvey: TSurvey = {
id: "survey1",
name: "Test Survey",
type: "link",
status: "inProgress",
questions: [],
thankYouCard: { enabled: false },
endings: [],
autoClose: null,
triggers: [],
languages: [],
styling: null,
} as unknown as TSurvey;
const mockSurveyUrl = "https://app.formbricks.com/s/survey1";
const mockPublicDomain = "https://app.formbricks.com";
const mockSetSurveyUrl = vi.fn();
const mockLocale: TUserLocale = "en-US";
const docsLinksExpected = [
{
titleKey: "environments.surveys.summary.data_prefilling",
descriptionKey: "environments.surveys.summary.data_prefilling_description",
link: "https://formbricks.com/docs/link-surveys/data-prefilling",
},
{
titleKey: "environments.surveys.summary.source_tracking",
descriptionKey: "environments.surveys.summary.source_tracking_description",
link: "https://formbricks.com/docs/link-surveys/source-tracking",
},
{
titleKey: "environments.surveys.summary.create_single_use_links",
descriptionKey: "environments.surveys.summary.create_single_use_links_description",
link: "https://formbricks.com/docs/link-surveys/single-use-links",
},
];
describe("LinkTab", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders the main title", () => {
render(
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
publicDomain={mockPublicDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
);
expect(
screen.getByText("environments.surveys.summary.share_the_link_to_get_responses")
).toBeInTheDocument();
});
test("renders ShareSurveyLink with correct props", () => {
render(
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
publicDomain={mockPublicDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
);
expect(screen.getByTestId("share-survey-link")).toBeInTheDocument();
expect(screen.getByTestId("survey-id")).toHaveTextContent(mockSurvey.id);
expect(screen.getByTestId("survey-url")).toHaveTextContent(mockSurveyUrl);
expect(screen.getByTestId("public-domain")).toHaveTextContent(mockPublicDomain);
expect(screen.getByTestId("locale")).toHaveTextContent(mockLocale);
});
test("renders the promotional text for link surveys", () => {
render(
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
publicDomain={mockPublicDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
);
expect(
screen.getByText("environments.surveys.summary.you_can_do_a_lot_more_with_links_surveys 💡")
).toBeInTheDocument();
});
test("renders all documentation links correctly", () => {
render(
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
publicDomain={mockPublicDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
);
docsLinksExpected.forEach((doc) => {
const linkElement = screen.getByText(doc.titleKey).closest("a");
expect(linkElement).toBeInTheDocument();
expect(linkElement).toHaveAttribute("href", doc.link);
expect(linkElement).toHaveAttribute("target", "_blank");
expect(screen.getByText(doc.descriptionKey)).toBeInTheDocument();
});
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.data_prefilling");
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.data_prefilling_description");
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.source_tracking");
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.source_tracking_description");
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.create_single_use_links");
expect(mockTranslate).toHaveBeenCalledWith(
"environments.surveys.summary.create_single_use_links_description"
);
});
});

View File

@@ -1,72 +0,0 @@
"use client";
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
import { useTranslate } from "@tolgee/react";
import Link from "next/link";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
interface LinkTabProps {
survey: TSurvey;
surveyUrl: string;
publicDomain: string;
setSurveyUrl: (url: string) => void;
locale: TUserLocale;
}
export const LinkTab = ({ survey, surveyUrl, publicDomain, setSurveyUrl, locale }: LinkTabProps) => {
const { t } = useTranslate();
const docsLinks = [
{
title: t("environments.surveys.summary.data_prefilling"),
description: t("environments.surveys.summary.data_prefilling_description"),
link: "https://formbricks.com/docs/link-surveys/data-prefilling",
},
{
title: t("environments.surveys.summary.source_tracking"),
description: t("environments.surveys.summary.source_tracking_description"),
link: "https://formbricks.com/docs/link-surveys/source-tracking",
},
{
title: t("environments.surveys.summary.create_single_use_links"),
description: t("environments.surveys.summary.create_single_use_links_description"),
link: "https://formbricks.com/docs/link-surveys/single-use-links",
},
];
return (
<div className="flex h-full grow flex-col gap-6">
<div>
<p className="text-lg font-semibold text-slate-800">
{t("environments.surveys.summary.share_the_link_to_get_responses")}
</p>
<ShareSurveyLink
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
</div>
<div className="flex flex-wrap justify-between gap-2">
<p className="pt-2 font-semibold text-slate-700">
{t("environments.surveys.summary.you_can_do_a_lot_more_with_links_surveys")} 💡
</p>
<div className="grid grid-cols-2 gap-2">
{docsLinks.map((tip) => (
<Link
key={tip.title}
target="_blank"
href={tip.link}
className="relative w-full rounded-md border border-slate-100 bg-white px-6 py-4 text-sm text-slate-600 hover:bg-slate-50 hover:text-slate-800">
<p className="mb-1 font-semibold">{tip.title}</p>
<p className="text-slate-500 hover:text-slate-700">{tip.description}</p>
</Link>
))}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,389 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { AnonymousLinksTab } from "./anonymous-links-tab";
// Mock actions
vi.mock("../../actions", () => ({
updateSingleUseLinksAction: vi.fn(),
}));
vi.mock("@/modules/survey/list/actions", () => ({
generateSingleUseIdsAction: vi.fn(),
}));
// Mock components
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
ShareSurveyLink: ({ surveyUrl, publicDomain }: any) => (
<div data-testid="share-survey-link">
<p>Survey URL: {surveyUrl}</p>
<p>Public Domain: {publicDomain}</p>
</div>
),
}));
vi.mock("@/modules/ui/components/advanced-option-toggle", () => ({
AdvancedOptionToggle: ({ children, htmlId, isChecked, onToggle, title }: any) => (
<div data-testid={`toggle-${htmlId}`} data-checked={isChecked}>
<button data-testid={`toggle-button-${htmlId}`} onClick={() => onToggle(!isChecked)}>
{title}
</button>
{children}
</div>
),
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children, variant, size }: any) => (
<div data-testid={`alert-${variant}`} data-size={size}>
{children}
</div>
),
AlertTitle: ({ children }: any) => <div data-testid="alert-title">{children}</div>,
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, disabled, variant }: any) => (
<button onClick={onClick} disabled={disabled} data-variant={variant}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/input", () => ({
Input: ({ value, onChange, type, max, min, className }: any) => (
<input
type={type}
max={max}
min={min}
className={className}
value={value}
onChange={onChange}
data-testid="number-input"
/>
),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container",
() => ({
TabContainer: ({ children, title }: any) => (
<div data-testid="tab-container">
<h2>{title}</h2>
{children}
</div>
),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal",
() => ({
DisableLinkModal: ({ open, type, onDisable }: any) => (
<div data-testid="disable-link-modal" data-open={open} data-type={type}>
<button onClick={() => onDisable()}>Confirm</button>
<button>Close</button>
</div>
),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links",
() => ({
DocumentationLinks: ({ links }: any) => (
<div data-testid="documentation-links">
{links.map((link: any, index: number) => (
<a key={index} href={link.href}>
{link.title}
</a>
))}
</div>
),
})
);
// Mock translations
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
// Mock Next.js router
const mockRefresh = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: mockRefresh,
}),
}));
// Mock toast
vi.mock("react-hot-toast", () => ({
default: {
error: vi.fn(),
success: vi.fn(),
},
}));
// Mock URL and Blob for download functionality
global.URL.createObjectURL = vi.fn(() => "mock-url");
global.URL.revokeObjectURL = vi.fn();
global.Blob = vi.fn(() => ({}) as any);
describe("AnonymousLinksTab", () => {
const mockSurvey = {
id: "test-survey-id",
environmentId: "test-env-id",
type: "link" as const,
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
createdBy: null,
status: "draft" as const,
questions: [],
thankYouCard: { enabled: false },
welcomeCard: { enabled: false },
hiddenFields: { enabled: false },
singleUse: {
enabled: false,
isEncrypted: false,
},
} as unknown as TSurvey;
const surveyWithSingleUse = {
...mockSurvey,
singleUse: {
enabled: true,
isEncrypted: false,
},
} as TSurvey;
const surveyWithEncryption = {
...mockSurvey,
singleUse: {
enabled: true,
isEncrypted: true,
},
} as TSurvey;
const defaultProps = {
survey: mockSurvey,
surveyUrl: "https://example.com/survey",
publicDomain: "https://example.com",
setSurveyUrl: vi.fn(),
locale: "en-US" as TUserLocale,
};
beforeEach(async () => {
vi.clearAllMocks();
const { updateSingleUseLinksAction } = await import("../../actions");
const { generateSingleUseIdsAction } = await import("@/modules/survey/list/actions");
vi.mocked(updateSingleUseLinksAction).mockResolvedValue({ data: mockSurvey });
vi.mocked(generateSingleUseIdsAction).mockResolvedValue({ data: ["link1", "link2"] });
});
afterEach(() => {
cleanup();
});
test("renders with multi-use link enabled by default", () => {
render(<AnonymousLinksTab {...defaultProps} />);
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
expect(screen.getByTestId("toggle-multi-use-link-switch")).toHaveAttribute("data-checked", "true");
expect(screen.getByTestId("toggle-single-use-link-switch")).toHaveAttribute("data-checked", "false");
});
test("renders with single-use link enabled when survey has singleUse enabled", () => {
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
expect(screen.getByTestId("toggle-multi-use-link-switch")).toHaveAttribute("data-checked", "false");
expect(screen.getByTestId("toggle-single-use-link-switch")).toHaveAttribute("data-checked", "true");
});
test("handles multi-use toggle when single-use is disabled", async () => {
const user = userEvent.setup();
const { updateSingleUseLinksAction } = await import("../../actions");
render(<AnonymousLinksTab {...defaultProps} />);
// When multi-use is enabled and we click it, it should show a modal to turn it off
const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch");
await user.click(multiUseToggle);
// Should show confirmation modal
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-open", "true");
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-type", "multi-use");
// Confirm the modal action
const confirmButton = screen.getByText("Confirm");
await user.click(confirmButton);
await waitFor(() => {
expect(updateSingleUseLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
environmentId: "test-env-id",
isSingleUse: true,
isSingleUseEncryption: true,
});
});
expect(mockRefresh).toHaveBeenCalled();
});
test("shows confirmation modal when toggling from single-use to multi-use", async () => {
const user = userEvent.setup();
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch");
await user.click(multiUseToggle);
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-open", "true");
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-type", "single-use");
});
test("shows confirmation modal when toggling from multi-use to single-use", async () => {
const user = userEvent.setup();
render(<AnonymousLinksTab {...defaultProps} />);
const singleUseToggle = screen.getByTestId("toggle-button-single-use-link-switch");
await user.click(singleUseToggle);
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-open", "true");
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-type", "multi-use");
});
test("handles single-use encryption toggle", async () => {
const user = userEvent.setup();
const { updateSingleUseLinksAction } = await import("../../actions");
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
const encryptionToggle = screen.getByTestId("toggle-button-single-use-encryption-switch");
await user.click(encryptionToggle);
await waitFor(() => {
expect(updateSingleUseLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
environmentId: "test-env-id",
isSingleUse: true,
isSingleUseEncryption: true,
});
});
});
test("shows encryption info alert when encryption is disabled", () => {
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
const alerts = screen.getAllByTestId("alert-info");
const encryptionAlert = alerts.find(
(alert) =>
alert.querySelector('[data-testid="alert-title"]')?.textContent ===
"environments.surveys.share.anonymous_links.custom_single_use_id_title"
);
expect(encryptionAlert).toBeInTheDocument();
expect(encryptionAlert?.querySelector('[data-testid="alert-title"]')).toHaveTextContent(
"environments.surveys.share.anonymous_links.custom_single_use_id_title"
);
});
test("shows link generation section when encryption is enabled", () => {
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithEncryption} />);
expect(screen.getByTestId("number-input")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.anonymous_links.generate_and_download_links")
).toBeInTheDocument();
});
test("handles number of links input change", async () => {
const user = userEvent.setup();
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithEncryption} />);
const input = screen.getByTestId("number-input");
await user.clear(input);
await user.type(input, "5");
expect(input).toHaveValue(5);
});
test("handles link generation error", async () => {
const user = userEvent.setup();
const { generateSingleUseIdsAction } = await import("@/modules/survey/list/actions");
vi.mocked(generateSingleUseIdsAction).mockResolvedValue({ data: undefined });
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithEncryption} />);
const generateButton = screen.getByText(
"environments.surveys.share.anonymous_links.generate_and_download_links"
);
await user.click(generateButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
"environments.surveys.share.anonymous_links.generate_links_error"
);
});
});
test("handles action error with generic message", async () => {
const user = userEvent.setup();
const { updateSingleUseLinksAction } = await import("../../actions");
vi.mocked(updateSingleUseLinksAction).mockResolvedValue({ data: undefined });
render(<AnonymousLinksTab {...defaultProps} />);
// Click multi-use toggle to show modal
const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch");
await user.click(multiUseToggle);
// Confirm the modal action
const confirmButton = screen.getByText("Confirm");
await user.click(confirmButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("common.something_went_wrong_please_try_again");
});
});
test("confirms modal action when disable link modal is confirmed", async () => {
const user = userEvent.setup();
const { updateSingleUseLinksAction } = await import("../../actions");
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch");
await user.click(multiUseToggle);
const confirmButton = screen.getByText("Confirm");
await user.click(confirmButton);
await waitFor(() => {
expect(updateSingleUseLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
environmentId: "test-env-id",
isSingleUse: false,
isSingleUseEncryption: false,
});
});
});
test("renders documentation links", () => {
render(<AnonymousLinksTab {...defaultProps} />);
expect(screen.getByTestId("documentation-links")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.anonymous_links.single_use_links")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.anonymous_links.data_prefilling")
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,346 @@
"use client";
import { updateSingleUseLinksAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions";
import { DisableLinkModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal";
import { DocumentationLinks } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links";
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
import { getSurveyUrl } from "@/modules/analysis/utils";
import { generateSingleUseIdsAction } from "@/modules/survey/list/actions";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { useTranslate } from "@tolgee/react";
import { CirclePlayIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
interface AnonymousLinksTabProps {
survey: TSurvey;
surveyUrl: string;
publicDomain: string;
setSurveyUrl: (url: string) => void;
locale: TUserLocale;
}
export const AnonymousLinksTab = ({
survey,
surveyUrl,
publicDomain,
setSurveyUrl,
locale,
}: AnonymousLinksTabProps) => {
const router = useRouter();
const { t } = useTranslate();
const [isMultiUseLink, setIsMultiUseLink] = useState(!survey.singleUse?.enabled);
const [isSingleUseLink, setIsSingleUseLink] = useState(survey.singleUse?.enabled ?? false);
const [singleUseEncryption, setSingleUseEncryption] = useState(survey.singleUse?.isEncrypted ?? false);
const [numberOfLinks, setNumberOfLinks] = useState<number | string>(1);
const [disableLinkModal, setDisableLinkModal] = useState<{
open: boolean;
type: "multi-use" | "single-use";
pendingAction: () => Promise<void> | void;
} | null>(null);
const resetState = () => {
const { singleUse } = survey;
const { enabled, isEncrypted } = singleUse ?? {};
setIsMultiUseLink(!enabled);
setIsSingleUseLink(enabled ?? false);
setSingleUseEncryption(isEncrypted ?? false);
};
const updateSingleUseSettings = async (
isSingleUse: boolean,
isSingleUseEncryption: boolean
): Promise<void> => {
try {
const updatedSurveyResponse = await updateSingleUseLinksAction({
surveyId: survey.id,
environmentId: survey.environmentId,
isSingleUse,
isSingleUseEncryption,
});
if (updatedSurveyResponse?.data) {
router.refresh();
return;
}
toast.error(t("common.something_went_wrong_please_try_again"));
resetState();
} catch {
toast.error(t("common.something_went_wrong_please_try_again"));
resetState();
}
};
const handleMultiUseToggle = async (newValue: boolean) => {
if (newValue) {
// Turning multi-use on - show confirmation modal if single-use is currently enabled
if (isSingleUseLink) {
setDisableLinkModal({
open: true,
type: "single-use",
pendingAction: async () => {
setIsMultiUseLink(true);
setIsSingleUseLink(false);
setSingleUseEncryption(false);
await updateSingleUseSettings(false, false);
},
});
} else {
// Single-use is already off, just enable multi-use
setIsMultiUseLink(true);
setIsSingleUseLink(false);
setSingleUseEncryption(false);
await updateSingleUseSettings(false, false);
}
} else {
// Turning multi-use off - need confirmation and turn single-use on
setDisableLinkModal({
open: true,
type: "multi-use",
pendingAction: async () => {
setIsMultiUseLink(false);
setIsSingleUseLink(true);
setSingleUseEncryption(true);
await updateSingleUseSettings(true, true);
},
});
}
};
const handleSingleUseToggle = async (newValue: boolean) => {
if (newValue) {
// Turning single-use on - turn multi-use off
setDisableLinkModal({
open: true,
type: "multi-use",
pendingAction: async () => {
setIsMultiUseLink(false);
setIsSingleUseLink(true);
setSingleUseEncryption(true);
await updateSingleUseSettings(true, true);
},
});
} else {
// Turning single-use off - show confirmation modal and then turn multi-use on
setDisableLinkModal({
open: true,
type: "single-use",
pendingAction: async () => {
setIsMultiUseLink(true);
setIsSingleUseLink(false);
setSingleUseEncryption(false);
await updateSingleUseSettings(false, false);
},
});
}
};
const handleSingleUseEncryptionToggle = async (newValue: boolean) => {
setSingleUseEncryption(newValue);
await updateSingleUseSettings(true, newValue);
};
const handleNumberOfLinksChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value;
if (inputValue === "") {
setNumberOfLinks("");
return;
}
const value = Number(inputValue);
if (!isNaN(value)) {
setNumberOfLinks(value);
}
};
const handleGenerateLinks = async (count: number) => {
try {
const response = await generateSingleUseIdsAction({
surveyId: survey.id,
isEncrypted: singleUseEncryption,
count,
});
const baseSurveyUrl = getSurveyUrl(survey, publicDomain, "default");
if (!!response?.data?.length) {
const singleUseIds = response.data;
const surveyLinks = singleUseIds.map((singleUseId) => `${baseSurveyUrl}?suId=${singleUseId}`);
// Create content with just the links
const csvContent = surveyLinks.join("\n");
// Create and download the file
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute("download", `single-use-links-${survey.id}.csv`);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return;
}
toast.error(t("environments.surveys.share.anonymous_links.generate_links_error"));
} catch (error) {
toast.error(t("environments.surveys.share.anonymous_links.generate_links_error"));
}
};
return (
<TabContainer
title={t("environments.surveys.share.anonymous_links.title")}
description={t("environments.surveys.share.anonymous_links.description")}>
<div className="flex h-full w-full grow flex-col gap-6">
<AdvancedOptionToggle
htmlId="multi-use-link-switch"
isChecked={isMultiUseLink}
onToggle={handleMultiUseToggle}
title={t("environments.surveys.share.anonymous_links.multi_use_link")}
description={t("environments.surveys.share.anonymous_links.multi_use_link_description")}
customContainerClass="p-0"
childBorder>
<div className="flex w-full flex-col gap-4 overflow-hidden bg-white p-4">
<ShareSurveyLink
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
<div className="w-full">
<Alert variant="info" size="default">
<AlertTitle>
{t("environments.surveys.share.anonymous_links.multi_use_powers_other_channels_title")}
</AlertTitle>
<AlertDescription>
{t(
"environments.surveys.share.anonymous_links.multi_use_powers_other_channels_description"
)}
</AlertDescription>
</Alert>
</div>
</div>
</AdvancedOptionToggle>
<AdvancedOptionToggle
htmlId="single-use-link-switch"
isChecked={isSingleUseLink}
onToggle={handleSingleUseToggle}
title={t("environments.surveys.share.anonymous_links.single_use_link")}
description={t("environments.surveys.share.anonymous_links.single_use_link_description")}
customContainerClass="p-0"
childBorder>
<div className="flex w-full flex-col gap-4 bg-white p-4">
<AdvancedOptionToggle
htmlId="single-use-encryption-switch"
isChecked={singleUseEncryption}
onToggle={handleSingleUseEncryptionToggle}
title={t("environments.surveys.share.anonymous_links.url_encryption_label")}
description={t("environments.surveys.share.anonymous_links.url_encryption_description")}
customContainerClass="p-0"
/>
{!singleUseEncryption ? (
<Alert variant="info" size="default">
<AlertTitle>
{t("environments.surveys.share.anonymous_links.custom_single_use_id_title")}
</AlertTitle>
<AlertDescription>
{t("environments.surveys.share.anonymous_links.custom_single_use_id_description")}
</AlertDescription>
</Alert>
) : null}
{singleUseEncryption && (
<div className="flex w-full flex-col gap-2">
<h3 className="text-sm font-medium text-slate-900">
{t("environments.surveys.share.anonymous_links.number_of_links_label")}
</h3>
<div className="flex w-full flex-col gap-2">
<div className="flex w-full items-center gap-2">
<div className="w-32">
<Input
type="number"
max={5000}
min={1}
className="bg-white focus:border focus:border-slate-900"
value={numberOfLinks}
onChange={handleNumberOfLinksChange}
/>
</div>
<Button
variant="default"
onClick={() => handleGenerateLinks(Number(numberOfLinks) || 1)}
disabled={Number(numberOfLinks) < 1 || Number(numberOfLinks) > 5000}>
<div className="flex items-center gap-2">
<CirclePlayIcon className="h-3.5 w-3.5 shrink-0 text-slate-50" />
</div>
<span className="text-sm text-slate-50">
{t("environments.surveys.share.anonymous_links.generate_and_download_links")}
</span>
</Button>
</div>
</div>
</div>
)}
</div>
</AdvancedOptionToggle>
</div>
<DocumentationLinks
links={[
{
title: t("environments.surveys.share.anonymous_links.single_use_links"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/single-use-links",
},
{
title: t("environments.surveys.share.anonymous_links.data_prefilling"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/data-prefilling",
},
{
title: t("environments.surveys.share.anonymous_links.source_tracking"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/source-tracking",
},
{
title: t("environments.surveys.share.anonymous_links.custom_start_point"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
},
]}
/>
{disableLinkModal && (
<DisableLinkModal
open={disableLinkModal.open}
onOpenChange={() => setDisableLinkModal(null)}
type={disableLinkModal.type}
onDisable={() => {
disableLinkModal.pendingAction();
setDisableLinkModal(null);
}}
/>
)}
</TabContainer>
);
};

View File

@@ -1,7 +1,7 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AppTab } from "./AppTab";
import { AppTab } from "./app-tab";
vi.mock("@/modules/ui/components/options-switch", () => ({
OptionsSwitch: (props: {

View File

@@ -0,0 +1,95 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { DisableLinkModal } from "./disable-link-modal";
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
const onOpenChange = vi.fn();
const onDisable = vi.fn();
describe("DisableLinkModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("should render the modal for multi-use link", () => {
render(
<DisableLinkModal open={true} onOpenChange={onOpenChange} type="multi-use" onDisable={onDisable} />
);
expect(
screen.getByText("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description")
).toBeInTheDocument();
expect(
screen.getByText(
"environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description_subtext"
)
).toBeInTheDocument();
});
test("should render the modal for single-use link", () => {
render(
<DisableLinkModal open={true} onOpenChange={onOpenChange} type="single-use" onDisable={onDisable} />
);
expect(screen.getByText("common.are_you_sure")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.anonymous_links.disable_single_use_link_modal_description")
).toBeInTheDocument();
});
test("should call onDisable and onOpenChange when the disable button is clicked for multi-use", async () => {
render(
<DisableLinkModal open={true} onOpenChange={onOpenChange} type="multi-use" onDisable={onDisable} />
);
const disableButton = screen.getByText(
"environments.surveys.share.anonymous_links.disable_multi_use_link_modal_button"
);
await userEvent.click(disableButton);
expect(onDisable).toHaveBeenCalled();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
test("should call onDisable and onOpenChange when the disable button is clicked for single-use", async () => {
render(
<DisableLinkModal open={true} onOpenChange={onOpenChange} type="single-use" onDisable={onDisable} />
);
const disableButton = screen.getByText(
"environments.surveys.share.anonymous_links.disable_single_use_link_modal_button"
);
await userEvent.click(disableButton);
expect(onDisable).toHaveBeenCalled();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
test("should call onOpenChange when the cancel button is clicked", async () => {
render(
<DisableLinkModal open={true} onOpenChange={onOpenChange} type="multi-use" onDisable={onDisable} />
);
const cancelButton = screen.getByText("common.cancel");
await userEvent.click(cancelButton);
expect(onOpenChange).toHaveBeenCalledWith(false);
});
test("should not render the modal when open is false", () => {
const { container } = render(
<DisableLinkModal open={false} onOpenChange={onOpenChange} type="multi-use" onDisable={onDisable} />
);
expect(container.firstChild).toBeNull();
});
});

View File

@@ -0,0 +1,71 @@
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
} from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
interface DisableLinkModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
type: "multi-use" | "single-use";
onDisable: () => void;
}
export const DisableLinkModal = ({ open, onOpenChange, type, onDisable }: DisableLinkModalProps) => {
const { t } = useTranslate();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent width="narrow" className="flex flex-col" hideCloseButton disableCloseOnOutsideClick>
<DialogHeader className="text-sm font-medium text-slate-900">
{type === "multi-use"
? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title")
: t("common.are_you_sure")}
</DialogHeader>
<DialogBody>
{type === "multi-use" ? (
<>
<p>
{t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description")}
</p>
<br />
<p>
{t(
"environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description_subtext"
)}
</p>
</>
) : (
<p>{t("environments.surveys.share.anonymous_links.disable_single_use_link_modal_description")}</p>
)}
</DialogBody>
<DialogFooter>
<div className="flex w-full flex-col gap-2">
<Button
variant="default"
onClick={() => {
onDisable();
onOpenChange(false);
}}>
{type === "multi-use"
? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_button")
: t("environments.surveys.share.anonymous_links.disable_single_use_link_modal_button")}
</Button>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,102 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { DocumentationLinks } from "./documentation-links";
describe("DocumentationLinks", () => {
afterEach(() => {
cleanup();
});
const mockLinks = [
{
title: "Getting Started Guide",
href: "https://docs.formbricks.com/getting-started",
},
{
title: "API Documentation",
href: "https://docs.formbricks.com/api",
},
{
title: "Integration Guide",
href: "https://docs.formbricks.com/integrations",
},
];
test("renders all documentation links", () => {
render(<DocumentationLinks links={mockLinks} />);
expect(screen.getByText("Getting Started Guide")).toBeInTheDocument();
expect(screen.getByText("API Documentation")).toBeInTheDocument();
expect(screen.getByText("Integration Guide")).toBeInTheDocument();
});
test("renders correct number of alert components", () => {
render(<DocumentationLinks links={mockLinks} />);
const alerts = screen.getAllByRole("alert");
expect(alerts).toHaveLength(3);
});
test("renders learn more links with correct href attributes", () => {
render(<DocumentationLinks links={mockLinks} />);
const learnMoreLinks = screen.getAllByText("common.learn_more");
expect(learnMoreLinks).toHaveLength(3);
expect(learnMoreLinks[0]).toHaveAttribute("href", "https://docs.formbricks.com/getting-started");
expect(learnMoreLinks[1]).toHaveAttribute("href", "https://docs.formbricks.com/api");
expect(learnMoreLinks[2]).toHaveAttribute("href", "https://docs.formbricks.com/integrations");
});
test("renders learn more links with target blank", () => {
render(<DocumentationLinks links={mockLinks} />);
const learnMoreLinks = screen.getAllByText("common.learn_more");
learnMoreLinks.forEach((link) => {
expect(link).toHaveAttribute("target", "_blank");
});
});
test("renders learn more links with correct CSS classes", () => {
render(<DocumentationLinks links={mockLinks} />);
const learnMoreLinks = screen.getAllByText("common.learn_more");
learnMoreLinks.forEach((link) => {
expect(link).toHaveClass("text-slate-900", "hover:underline");
});
});
test("renders empty list when no links provided", () => {
render(<DocumentationLinks links={[]} />);
const alerts = screen.queryAllByRole("alert");
expect(alerts).toHaveLength(0);
});
test("renders single link correctly", () => {
const singleLink = [mockLinks[0]];
render(<DocumentationLinks links={singleLink} />);
expect(screen.getByText("Getting Started Guide")).toBeInTheDocument();
expect(screen.getByText("common.learn_more")).toBeInTheDocument();
expect(screen.getByText("common.learn_more")).toHaveAttribute(
"href",
"https://docs.formbricks.com/getting-started"
);
});
test("renders with correct container structure", () => {
const { container } = render(<DocumentationLinks links={mockLinks} />);
const mainContainer = container.firstChild as HTMLElement;
expect(mainContainer).toHaveClass("flex", "w-full", "flex-col", "space-y-2");
const linkContainers = mainContainer.children;
expect(linkContainers).toHaveLength(3);
Array.from(linkContainers).forEach((linkContainer) => {
expect(linkContainer).toHaveClass("flex", "w-full", "flex-col", "gap-3");
});
});
});

View File

@@ -0,0 +1,37 @@
"use client";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { useTranslate } from "@tolgee/react";
import Link from "next/link";
interface DocumentationLinksProps {
links: {
title: string;
href: string;
}[];
}
export const DocumentationLinks = ({ links }: DocumentationLinksProps) => {
const { t } = useTranslate();
return (
<div className="flex w-full flex-col space-y-2">
{links.map((link) => (
<div key={link.title} className="flex w-full flex-col gap-3">
<Alert variant="outbound" size="small">
<AlertTitle>{link.title}</AlertTitle>
<AlertButton asChild>
<Link
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="text-slate-900 hover:underline">
{t("common.learn_more")}
</Link>
</AlertButton>
</Alert>
</div>
))}
</div>
);
};

View File

@@ -1,7 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { DynamicPopupTab } from "./DynamicPopupTab";
import { DynamicPopupTab } from "./dynamic-popup-tab";
// Mock components
vi.mock("@/modules/ui/components/alert", () => ({
@@ -30,7 +30,13 @@ vi.mock("@/modules/ui/components/button", () => ({
}));
vi.mock("@/modules/ui/components/typography", () => ({
H3: (props: { children: React.ReactNode }) => <div data-testid="h3">{props.children}</div>,
H4: (props: { children: React.ReactNode }) => <div data-testid="h4">{props.children}</div>,
Small: (props: { children: React.ReactNode; color?: string; margin?: string }) => (
<div data-testid="small" data-color={props.color} data-margin={props.margin}>
{props.children}
</div>
),
}));
vi.mock("@tolgee/react", () => ({
@@ -69,18 +75,20 @@ describe("DynamicPopupTab", () => {
test("renders alert with correct props", () => {
render(<DynamicPopupTab {...defaultProps} />);
const alert = screen.getByTestId("alert");
expect(alert).toBeInTheDocument();
expect(alert).toHaveAttribute("data-variant", "info");
expect(alert).toHaveAttribute("data-size", "default");
const alerts = screen.getAllByTestId("alert");
const infoAlert = alerts.find((alert) => alert.getAttribute("data-variant") === "info");
expect(infoAlert).toBeInTheDocument();
expect(infoAlert).toHaveAttribute("data-variant", "info");
expect(infoAlert).toHaveAttribute("data-size", "default");
});
test("renders alert title with translation key", () => {
render(<DynamicPopupTab {...defaultProps} />);
const alertTitle = screen.getByTestId("alert-title");
expect(alertTitle).toBeInTheDocument();
expect(alertTitle).toHaveTextContent("environments.surveys.summary.dynamic_popup.alert_title");
const alertTitles = screen.getAllByTestId("alert-title");
const infoAlertTitle = alertTitles[0]; // The first one is the info alert
expect(infoAlertTitle).toBeInTheDocument();
expect(infoAlertTitle).toHaveTextContent("environments.surveys.share.dynamic_popup.alert_title");
});
test("renders alert description with translation key", () => {
@@ -88,38 +96,37 @@ describe("DynamicPopupTab", () => {
const alertDescription = screen.getByTestId("alert-description");
expect(alertDescription).toBeInTheDocument();
expect(alertDescription).toHaveTextContent(
"environments.surveys.summary.dynamic_popup.alert_description"
);
expect(alertDescription).toHaveTextContent("environments.surveys.share.dynamic_popup.alert_description");
});
test("renders alert button with link to survey edit page", () => {
render(<DynamicPopupTab {...defaultProps} />);
const alertButton = screen.getByTestId("alert-button");
expect(alertButton).toBeInTheDocument();
expect(alertButton).toHaveAttribute("data-as-child", "true");
const alertButtons = screen.getAllByTestId("alert-button");
const infoAlertButton = alertButtons[0]; // The first one is the info alert
expect(infoAlertButton).toBeInTheDocument();
expect(infoAlertButton).toHaveAttribute("data-as-child", "true");
const link = screen.getAllByTestId("next-link")[0];
expect(link).toHaveAttribute("href", "/environments/env-123/surveys/survey-123/edit");
expect(link).toHaveTextContent("environments.surveys.summary.dynamic_popup.alert_button");
expect(link).toHaveTextContent("environments.surveys.share.dynamic_popup.alert_button");
});
test("renders title with correct text", () => {
render(<DynamicPopupTab {...defaultProps} />);
const h4 = screen.getByTestId("h4");
expect(h4).toBeInTheDocument();
expect(h4).toHaveTextContent("environments.surveys.summary.dynamic_popup.title");
const h3 = screen.getByTestId("h3");
expect(h3).toBeInTheDocument();
expect(h3).toHaveTextContent("environments.surveys.share.dynamic_popup.title");
});
test("renders attribute-based targeting documentation button", () => {
render(<DynamicPopupTab {...defaultProps} />);
const links = screen.getAllByTestId("next-link");
const links = screen.getAllByRole("link");
const attributeLink = links.find((link) => link.getAttribute("href")?.includes("advanced-targeting"));
expect(attributeLink).toBeInTheDocument();
expect(attributeLink).toBeDefined();
expect(attributeLink).toHaveAttribute(
"href",
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting"
@@ -130,10 +137,10 @@ describe("DynamicPopupTab", () => {
test("renders code and no code triggers documentation button", () => {
render(<DynamicPopupTab {...defaultProps} />);
const links = screen.getAllByTestId("next-link");
const links = screen.getAllByRole("link");
const actionsLink = links.find((link) => link.getAttribute("href")?.includes("actions"));
expect(actionsLink).toBeInTheDocument();
expect(actionsLink).toBeDefined();
expect(actionsLink).toHaveAttribute(
"href",
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions"
@@ -144,10 +151,10 @@ describe("DynamicPopupTab", () => {
test("renders recontact options documentation button", () => {
render(<DynamicPopupTab {...defaultProps} />);
const links = screen.getAllByTestId("next-link");
const links = screen.getAllByRole("link");
const recontactLink = links.find((link) => link.getAttribute("href")?.includes("recontact"));
expect(recontactLink).toBeInTheDocument();
expect(recontactLink).toBeDefined();
expect(recontactLink).toHaveAttribute(
"href",
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/recontact"
@@ -158,18 +165,27 @@ describe("DynamicPopupTab", () => {
test("all documentation buttons have external link icons", () => {
render(<DynamicPopupTab {...defaultProps} />);
const externalLinkIcons = screen.getAllByTestId("external-link-icon");
expect(externalLinkIcons).toHaveLength(3);
const links = screen.getAllByRole("link");
const documentationLinks = links.filter(
(link) =>
link.getAttribute("href")?.includes("formbricks.com/docs") && link.getAttribute("target") === "_blank"
);
externalLinkIcons.forEach((icon) => {
expect(icon).toHaveClass("h-4 w-4 flex-shrink-0");
// There are 3 unique documentation URLs
expect(documentationLinks).toHaveLength(3);
documentationLinks.forEach((link) => {
expect(link).toHaveAttribute("target", "_blank");
});
});
test("documentation button links open in new tab", () => {
render(<DynamicPopupTab {...defaultProps} />);
const documentationLinks = screen.getAllByTestId("next-link").slice(1, 4); // Skip the alert button link
const links = screen.getAllByRole("link");
const documentationLinks = links.filter((link) =>
link.getAttribute("href")?.includes("formbricks.com/docs")
);
documentationLinks.forEach((link) => {
expect(link).toHaveAttribute("target", "_blank");

View File

@@ -0,0 +1,53 @@
"use client";
import { DocumentationLinks } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links";
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { useTranslate } from "@tolgee/react";
import Link from "next/link";
interface DynamicPopupTabProps {
environmentId: string;
surveyId: string;
}
export const DynamicPopupTab = ({ environmentId, surveyId }: DynamicPopupTabProps) => {
const { t } = useTranslate();
return (
<TabContainer
title={t("environments.surveys.share.dynamic_popup.title")}
description={t("environments.surveys.share.dynamic_popup.description")}>
<div className="flex h-full flex-col justify-between space-y-4">
<Alert variant="info" size="default">
<AlertTitle>{t("environments.surveys.share.dynamic_popup.alert_title")}</AlertTitle>
<AlertDescription>
{t("environments.surveys.share.dynamic_popup.alert_description")}
</AlertDescription>
<AlertButton asChild>
<Link href={`/environments/${environmentId}/surveys/${surveyId}/edit`}>
{t("environments.surveys.share.dynamic_popup.alert_button")}
</Link>
</AlertButton>
</Alert>
<DocumentationLinks
links={[
{
title: t("environments.surveys.share.dynamic_popup.attribute_based_targeting"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting",
},
{
title: t("environments.surveys.share.dynamic_popup.code_no_code_triggers"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions",
},
{
title: t("environments.surveys.share.dynamic_popup.recontact_options"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/recontact",
},
]}
/>
</div>
</TabContainer>
);
};

View File

@@ -5,7 +5,7 @@ import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { AuthenticationError } from "@formbricks/types/errors";
import { getEmailHtmlAction, sendEmbedSurveyPreviewEmailAction } from "../../actions";
import { EmailTab } from "./EmailTab";
import { EmailTab } from "./email-tab";
// Mock actions
vi.mock("../../actions", () => ({
@@ -20,15 +20,23 @@ vi.mock("@/lib/utils/helper", () => ({
// Mock UI components
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, variant, title, ...props }: any) => (
<button onClick={onClick} data-variant={variant} title={title} {...props}>
Button: ({ children, onClick, variant, title, "aria-label": ariaLabel, ...props }: any) => (
<button onClick={onClick} data-variant={variant} title={title} aria-label={ariaLabel} {...props}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/code-block", () => ({
CodeBlock: ({ children, language }: { children: React.ReactNode; language: string }) => (
<div data-testid="code-block" data-language={language}>
CodeBlock: ({
children,
language,
showCopyToClipboard,
}: {
children: React.ReactNode;
language: string;
showCopyToClipboard?: boolean;
}) => (
<div data-testid="code-block" data-language={language} data-show-copy={showCopyToClipboard}>
{children}
</div>
),
@@ -41,7 +49,9 @@ vi.mock("@/modules/ui/components/loading-spinner", () => ({
vi.mock("lucide-react", () => ({
Code2Icon: () => <div data-testid="code2-icon" />,
CopyIcon: () => <div data-testid="copy-icon" />,
EyeIcon: () => <div data-testid="eye-icon" />,
MailIcon: () => <div data-testid="mail-icon" />,
SendIcon: () => <div data-testid="send-icon" />,
}));
// Mock navigator.clipboard
@@ -74,22 +84,42 @@ describe("EmailTab", () => {
expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalledWith({ surveyId });
// Buttons
expect(screen.getByRole("button", { name: "send preview email" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "environments.surveys.summary.view_embed_code_for_email" })
screen.getByRole("button", { name: "environments.surveys.share.send_email.send_preview_email" })
).toBeInTheDocument();
expect(screen.getByTestId("mail-icon")).toBeInTheDocument();
expect(screen.getByTestId("code2-icon")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "environments.surveys.share.send_email.embed_code_tab" })
).toBeInTheDocument();
expect(screen.getByTestId("send-icon")).toBeInTheDocument();
// Note: code2-icon is only visible in the embed code tab, not in initial render
// Email preview section
await waitFor(() => {
expect(screen.getByText(`To : ${userEmail}`)).toBeInTheDocument();
const emailToElements = screen.getAllByText((content, element) => {
return (
element?.textContent?.includes("environments.surveys.share.send_email.email_to_label") || false
);
});
expect(emailToElements.length).toBeGreaterThan(0);
});
expect(
screen.getByText("Subject : environments.surveys.summary.formbricks_email_survey_preview")
).toBeInTheDocument();
screen.getAllByText((content, element) => {
return (
element?.textContent?.includes("environments.surveys.share.send_email.email_subject_label") || false
);
}).length
).toBeGreaterThan(0);
expect(
screen.getAllByText((content, element) => {
return (
element?.textContent?.includes(
"environments.surveys.share.send_email.formbricks_email_survey_preview"
) || false
);
}).length
).toBeGreaterThan(0);
await waitFor(() => {
expect(screen.getByText("Hello World ?preview=true&foo=bar")).toBeInTheDocument(); // Raw HTML content
expect(screen.getByText("Hello World ?foo=bar")).toBeInTheDocument(); // HTML content rendered as text (preview=true removed)
});
expect(screen.queryByTestId("code-block")).not.toBeInTheDocument();
});
@@ -99,32 +129,47 @@ describe("EmailTab", () => {
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const viewEmbedButton = screen.getByRole("button", {
name: "environments.surveys.summary.view_embed_code_for_email",
name: "environments.surveys.share.send_email.embed_code_tab",
});
await userEvent.click(viewEmbedButton);
// Embed code view
expect(screen.getByRole("button", { name: "Embed survey in your website" })).toBeInTheDocument(); // Updated name
expect(
screen.getByRole("button", { name: "environments.surveys.summary.view_embed_code_for_email" }) // Updated name for hide button
screen.getByRole("button", { name: "environments.surveys.share.send_email.copy_embed_code" })
).toBeInTheDocument();
expect(screen.getByTestId("copy-icon")).toBeInTheDocument();
const codeBlock = screen.getByTestId("code-block");
expect(codeBlock).toBeInTheDocument();
expect(codeBlock).toHaveTextContent(mockCleanedEmailHtml); // Cleaned HTML
expect(screen.queryByText(`To : ${userEmail}`)).not.toBeInTheDocument();
// Toggle back
const hideEmbedButton = screen.getByRole("button", {
name: "environments.surveys.summary.view_embed_code_for_email", // Updated name for hide button
});
await userEvent.click(hideEmbedButton);
expect(screen.getByRole("button", { name: "send preview email" })).toBeInTheDocument();
// The email_to_label should not be visible in embed code view
expect(
screen.getByRole("button", { name: "environments.surveys.summary.view_embed_code_for_email" })
screen.queryByText((content, element) => {
return (
element?.textContent?.includes("environments.surveys.share.send_email.email_to_label") || false
);
})
).not.toBeInTheDocument();
// Toggle back to preview
const previewButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.email_preview_tab",
});
await userEvent.click(previewButton);
expect(
screen.getByRole("button", { name: "environments.surveys.share.send_email.send_preview_email" })
).toBeInTheDocument();
expect(screen.getByText(`To : ${userEmail}`)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "environments.surveys.share.send_email.embed_code_tab" })
).toBeInTheDocument();
await waitFor(() => {
const emailToElements = screen.getAllByText((content, element) => {
return (
element?.textContent?.includes("environments.surveys.share.send_email.email_to_label") || false
);
});
expect(emailToElements.length).toBeGreaterThan(0);
});
expect(screen.queryByTestId("code-block")).not.toBeInTheDocument();
});
@@ -133,16 +178,19 @@ describe("EmailTab", () => {
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const viewEmbedButton = screen.getByRole("button", {
name: "environments.surveys.summary.view_embed_code_for_email",
name: "environments.surveys.share.send_email.embed_code_tab",
});
await userEvent.click(viewEmbedButton);
// Ensure this line queries by the correct aria-label
const copyCodeButton = screen.getByRole("button", { name: "Embed survey in your website" });
const copyCodeButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.copy_embed_code",
});
await userEvent.click(copyCodeButton);
expect(mockWriteText).toHaveBeenCalledWith(mockCleanedEmailHtml);
expect(toast.success).toHaveBeenCalledWith("environments.surveys.summary.embed_code_copied_to_clipboard");
expect(toast.success).toHaveBeenCalledWith(
"environments.surveys.share.send_email.embed_code_copied_to_clipboard"
);
});
test("sends preview email successfully", async () => {
@@ -150,11 +198,13 @@ describe("EmailTab", () => {
render(<EmailTab surveyId={surveyId} email={userEmail} />);
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
const sendPreviewButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.send_preview_email",
});
await userEvent.click(sendPreviewButton);
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
expect(toast.success).toHaveBeenCalledWith("environments.surveys.summary.email_sent");
expect(toast.success).toHaveBeenCalledWith("environments.surveys.share.send_email.email_sent");
});
test("handles send preview email failure (server error)", async () => {
@@ -163,7 +213,9 @@ describe("EmailTab", () => {
render(<EmailTab surveyId={surveyId} email={userEmail} />);
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
const sendPreviewButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.send_preview_email",
});
await userEvent.click(sendPreviewButton);
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
@@ -176,7 +228,9 @@ describe("EmailTab", () => {
render(<EmailTab surveyId={surveyId} email={userEmail} />);
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
const sendPreviewButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.send_preview_email",
});
await userEvent.click(sendPreviewButton);
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
@@ -190,7 +244,9 @@ describe("EmailTab", () => {
render(<EmailTab surveyId={surveyId} email={userEmail} />);
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
const sendPreviewButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.send_preview_email",
});
await userEvent.click(sendPreviewButton);
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
@@ -208,14 +264,19 @@ describe("EmailTab", () => {
test("renders default email if email prop is not provided", async () => {
render(<EmailTab surveyId={surveyId} email="" />);
await waitFor(() => {
expect(screen.getByText("To : user@mail.com")).toBeInTheDocument();
expect(
screen.getByText((content, element) => {
return (
element?.textContent === "environments.surveys.share.send_email.email_to_label : user@mail.com"
);
})
).toBeInTheDocument();
});
});
test("emailHtml memo removes various ?preview=true patterns", async () => {
const htmlWithVariants =
"<p>Test1 ?preview=true</p><p>Test2 ?preview=true&amp;next</p><p>Test3 ?preview=true&;next</p>";
// Ensure this line matches the "Received" output from your test error
const expectedCleanHtml = "<p>Test1 </p><p>Test2 ?next</p><p>Test3 ?next</p>";
vi.mocked(getEmailHtmlAction).mockResolvedValue({ data: htmlWithVariants });
@@ -223,7 +284,7 @@ describe("EmailTab", () => {
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const viewEmbedButton = screen.getByRole("button", {
name: "environments.surveys.summary.view_embed_code_for_email",
name: "environments.surveys.share.send_email.embed_code_tab",
});
await userEvent.click(viewEmbedButton);

View File

@@ -0,0 +1,160 @@
"use client";
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { CodeBlock } from "@/modules/ui/components/code-block";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { TabBar } from "@/modules/ui/components/tab-bar";
import { useTranslate } from "@tolgee/react";
import DOMPurify from "dompurify";
import { CopyIcon, SendIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { AuthenticationError } from "@formbricks/types/errors";
import { getEmailHtmlAction, sendEmbedSurveyPreviewEmailAction } from "../../actions";
interface EmailTabProps {
surveyId: string;
email: string;
}
export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
const [activeTab, setActiveTab] = useState("preview");
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
const { t } = useTranslate();
const emailHtml = useMemo(() => {
if (!emailHtmlPreview) return "";
return emailHtmlPreview
.replaceAll("?preview=true&amp;", "?")
.replaceAll("?preview=true&;", "?")
.replaceAll("?preview=true", "");
}, [emailHtmlPreview]);
const tabs = [
{
id: "preview",
label: t("environments.surveys.share.send_email.email_preview_tab"),
},
{
id: "embed",
label: t("environments.surveys.share.send_email.embed_code_tab"),
},
];
useEffect(() => {
const getData = async () => {
const emailHtml = await getEmailHtmlAction({ surveyId });
setEmailHtmlPreview(emailHtml?.data || "");
};
getData();
}, [surveyId]);
const sendPreviewEmail = async () => {
try {
const val = await sendEmbedSurveyPreviewEmailAction({ surveyId });
if (val?.data) {
toast.success(t("environments.surveys.share.send_email.email_sent"));
} else {
const errorMessage = getFormattedErrorMessage(val);
toast.error(errorMessage);
}
} catch (err) {
if (err instanceof AuthenticationError) {
toast.error(t("common.not_authenticated"));
return;
}
toast.error(t("common.something_went_wrong_please_try_again"));
}
};
const renderTabContent = () => {
if (activeTab === "preview") {
return (
<div className="space-y-4 pb-4">
<div className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4">
<div className="mb-6 flex gap-2">
<div className="h-3 w-3 rounded-full bg-red-500" />
<div className="h-3 w-3 rounded-full bg-amber-500" />
<div className="h-3 w-3 rounded-full bg-emerald-500" />
</div>
<div>
<div className="mb-2 border-b border-slate-200 pb-2 text-sm">
{t("environments.surveys.share.send_email.email_to_label")} : {email || "user@mail.com"}
</div>
<div className="border-b border-slate-200 pb-2 text-sm">
{t("environments.surveys.share.send_email.email_subject_label")} :{" "}
{t("environments.surveys.share.send_email.formbricks_email_survey_preview")}
</div>
<div className="p-2">
{emailHtml ? (
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(emailHtml) }} />
) : (
<LoadingSpinner />
)}
</div>
</div>
</div>
<Button
title={t("environments.surveys.share.send_email.send_preview_email")}
aria-label={t("environments.surveys.share.send_email.send_preview_email")}
onClick={() => sendPreviewEmail()}
className="shrink-0">
{t("environments.surveys.share.send_email.send_preview")}
<SendIcon />
</Button>
</div>
);
}
if (activeTab === "embed") {
return (
<div className="space-y-4 pb-4">
<CodeBlock
customCodeClass="text-sm h-96 overflow-y-scroll"
language="html"
showCopyToClipboard
noMargin>
{emailHtml}
</CodeBlock>
<Button
title={t("environments.surveys.share.send_email.copy_embed_code")}
aria-label={t("environments.surveys.share.send_email.copy_embed_code")}
onClick={() => {
try {
navigator.clipboard.writeText(emailHtml);
toast.success(t("environments.surveys.share.send_email.embed_code_copied_to_clipboard"));
} catch {
toast.error(t("environments.surveys.share.send_email.embed_code_copied_to_clipboard_failed"));
}
}}
className="shrink-0">
{t("common.copy_code")}
<CopyIcon />
</Button>
</div>
);
}
return null;
};
return (
<TabContainer
title={t("environments.surveys.share.send_email.title")}
description={t("environments.surveys.share.send_email.description")}>
<div className="flex h-full w-full flex-col space-y-4">
<TabBar
tabs={tabs}
activeId={activeTab}
setActiveId={setActiveTab}
tabStyle="button"
className="h-10 min-h-10 rounded-md border border-slate-200 bg-slate-100"
/>
<div className="flex-1">{renderTabContent()}</div>
</div>
</TabContainer>
);
};

View File

@@ -195,12 +195,8 @@ describe("PersonalLinksTab", () => {
test("renders the component with correct title and description", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(
screen.getByText("environments.surveys.summary.generate_personal_links_title")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.generate_personal_links_description")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.share.personal_links.title")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.share.personal_links.description")).toBeInTheDocument();
});
test("renders recipients section with segment selection", () => {
@@ -208,15 +204,21 @@ describe("PersonalLinksTab", () => {
expect(screen.getByText("common.recipients")).toBeInTheDocument();
expect(screen.getByTestId("select")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.create_and_manage_segments")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.personal_links.create_and_manage_segments")
).toBeInTheDocument();
});
test("renders expiry date section with date picker", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(screen.getByText("environments.surveys.summary.expiry_date_optional")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.personal_links.expiry_date_optional")
).toBeInTheDocument();
expect(screen.getByTestId("date-picker")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.expiry_date_description")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.personal_links.expiry_date_description")
).toBeInTheDocument();
});
test("renders generate button with correct initial state", () => {
@@ -225,7 +227,9 @@ describe("PersonalLinksTab", () => {
const button = screen.getByTestId("button");
expect(button).toBeInTheDocument();
expect(button).toBeDisabled();
expect(screen.getByText("environments.surveys.summary.generate_and_download_links")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.personal_links.generate_and_download_links")
).toBeInTheDocument();
expect(screen.getByTestId("download-icon")).toBeInTheDocument();
});
@@ -234,7 +238,7 @@ describe("PersonalLinksTab", () => {
expect(screen.getByTestId("alert")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.personal_links_work_with_segments")
screen.getByText("environments.surveys.share.personal_links.work_with_segments")
).toBeInTheDocument();
expect(screen.getByTestId("link")).toHaveAttribute(
"href",
@@ -259,7 +263,9 @@ describe("PersonalLinksTab", () => {
render(<PersonalLinksTab {...propsWithPrivateSegments} />);
expect(screen.getByText("environments.surveys.summary.no_segments_available")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.personal_links.no_segments_available")
).toBeInTheDocument();
expect(screen.getByTestId("select")).toHaveAttribute("data-disabled", "true");
expect(screen.getByTestId("button")).toBeDisabled();
});
@@ -341,10 +347,13 @@ describe("PersonalLinksTab", () => {
});
// Verify loading toast
expect(mockToast.loading).toHaveBeenCalledWith("environments.surveys.summary.generating_links_toast", {
duration: 5000,
id: "generating-links",
});
expect(mockToast.loading).toHaveBeenCalledWith(
"environments.surveys.share.personal_links.generating_links_toast",
{
duration: 5000,
id: "generating-links",
}
);
});
test("generates links with expiry date when date is selected", async () => {
@@ -439,10 +448,13 @@ describe("PersonalLinksTab", () => {
fireEvent.click(generateButton);
// Verify loading toast is called
expect(mockToast.loading).toHaveBeenCalledWith("environments.surveys.summary.generating_links_toast", {
duration: 5000,
id: "generating-links",
});
expect(mockToast.loading).toHaveBeenCalledWith(
"environments.surveys.share.personal_links.generating_links_toast",
{
duration: 5000,
id: "generating-links",
}
);
});
test("button is disabled when no segment is selected", () => {
@@ -472,7 +484,9 @@ describe("PersonalLinksTab", () => {
render(<PersonalLinksTab {...propsWithEmptySegments} />);
expect(screen.getByText("environments.surveys.summary.no_segments_available")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.personal_links.no_segments_available")
).toBeInTheDocument();
expect(screen.getByTestId("button")).toBeDisabled();
});

View File

@@ -1,9 +1,17 @@
"use client";
import { DocumentationLinks } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DatePicker } from "@/modules/ui/components/date-picker";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import {
Select,
SelectContent,
@@ -14,11 +22,12 @@ import {
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { useTranslate } from "@tolgee/react";
import { DownloadIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { TSegment } from "@formbricks/types/segment";
import { generatePersonalLinksAction } from "../../actions";
import { TabContainer } from "./tab-container";
interface PersonalLinksTabProps {
environmentId: string;
@@ -28,6 +37,11 @@ interface PersonalLinksTabProps {
isFormbricksCloud: boolean;
}
interface PersonalLinksFormData {
selectedSegment: string;
expiryDate: Date | null;
}
// Custom DatePicker component with date restrictions
const RestrictedDatePicker = ({
date,
@@ -63,8 +77,18 @@ export const PersonalLinksTab = ({
isFormbricksCloud,
}: PersonalLinksTabProps) => {
const { t } = useTranslate();
const [selectedSegment, setSelectedSegment] = useState<string>("");
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
const form = useForm<PersonalLinksFormData>({
defaultValues: {
selectedSegment: "",
expiryDate: null,
},
});
const { watch } = form;
const selectedSegment = watch("selectedSegment");
const expiryDate = watch("expiryDate");
const [isGenerating, setIsGenerating] = useState(false);
const publicSegments = segments.filter((segment) => !segment.isPrivate);
@@ -84,7 +108,7 @@ export const PersonalLinksTab = ({
setIsGenerating(true);
// Show initial toast
toast.loading(t("environments.surveys.summary.generating_links_toast"), {
toast.loading(t("environments.surveys.share.personal_links.generating_links_toast"), {
duration: 5000,
id: "generating-links",
});
@@ -100,7 +124,7 @@ export const PersonalLinksTab = ({
if (result?.data) {
downloadFile(result.data.downloadUrl, result.data.fileName || "personal-links.csv");
toast.success(t("environments.surveys.summary.links_generated_success_toast"), {
toast.success(t("environments.surveys.share.personal_links.links_generated_success_toast"), {
duration: 5000,
id: "generating-links",
});
@@ -117,14 +141,14 @@ export const PersonalLinksTab = ({
// Button state logic
const isButtonDisabled = !selectedSegment || isGenerating || publicSegments.length === 0;
const buttonText = isGenerating
? t("environments.surveys.summary.generating_links")
: t("environments.surveys.summary.generate_and_download_links");
? t("environments.surveys.share.personal_links.generating_links")
: t("environments.surveys.share.personal_links.generate_and_download_links");
if (!isContactsEnabled) {
return (
<UpgradePrompt
title={t("environments.surveys.summary.personal_links_upgrade_prompt_title")}
description={t("environments.surveys.summary.personal_links_upgrade_prompt_description")}
title={t("environments.surveys.share.personal_links.upgrade_prompt_title")}
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
buttons={[
{
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
@@ -144,88 +168,87 @@ export const PersonalLinksTab = ({
}
return (
<div className="flex h-full grow flex-col gap-6">
<div>
<h2 className="mb-2 text-lg font-semibold text-slate-800">
{t("environments.surveys.summary.generate_personal_links_title")}
</h2>
<p className="text-sm text-slate-600">
{t("environments.surveys.summary.generate_personal_links_description")}
</p>
</div>
<FormProvider {...form}>
<TabContainer
title={t("environments.surveys.share.personal_links.title")}
description={t("environments.surveys.share.personal_links.description")}>
<div className="flex h-full grow flex-col gap-6">
{/* Recipients Section */}
<FormField
control={form.control}
name="selectedSegment"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common.recipients")}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={publicSegments.length === 0}>
<SelectTrigger className="w-full bg-white">
<SelectValue
placeholder={
publicSegments.length === 0
? t("environments.surveys.share.personal_links.no_segments_available")
: t("environments.surveys.share.personal_links.select_segment")
}
/>
</SelectTrigger>
<SelectContent>
{publicSegments.map((segment) => (
<SelectItem key={segment.id} value={segment.id}>
{segment.title}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
{t("environments.surveys.share.personal_links.create_and_manage_segments")}
</FormDescription>
</FormItem>
)}
/>
<div className="space-y-6">
{/* Recipients Section */}
<div>
<label htmlFor="segment-select" className="mb-2 block text-sm font-medium text-slate-700">
{t("common.recipients")}
</label>
<Select
value={selectedSegment}
onValueChange={setSelectedSegment}
disabled={publicSegments.length === 0}>
<SelectTrigger id="segment-select" className="w-full bg-white">
<SelectValue
placeholder={
publicSegments.length === 0
? t("environments.surveys.summary.no_segments_available")
: t("environments.surveys.summary.select_segment")
}
/>
</SelectTrigger>
<SelectContent>
{publicSegments.map((segment) => (
<SelectItem key={segment.id} value={segment.id}>
{segment.title}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-slate-500">
{t("environments.surveys.summary.create_and_manage_segments")}
</p>
{/* Expiry Date Section */}
<FormField
control={form.control}
name="expiryDate"
render={({ field }) => (
<FormItem>
<FormLabel>{t("environments.surveys.share.personal_links.expiry_date_optional")}</FormLabel>
<FormControl>
<RestrictedDatePicker date={field.value} updateSurveyDate={field.onChange} />
</FormControl>
<FormDescription>
{t("environments.surveys.share.personal_links.expiry_date_description")}
</FormDescription>
</FormItem>
)}
/>
{/* Generate Button */}
<Button
onClick={handleGenerateLinks}
disabled={isButtonDisabled}
loading={isGenerating}
className="w-fit">
<DownloadIcon className="mr-2 h-4 w-4" />
{buttonText}
</Button>
</div>
<hr />
{/* Expiry Date Section */}
<div>
<label htmlFor="expiry-date-picker" className="mb-2 block text-sm font-medium text-slate-700">
{t("environments.surveys.summary.expiry_date_optional")}
</label>
<div id="expiry-date-picker">
<RestrictedDatePicker
date={expiryDate}
updateSurveyDate={(date: Date | null) => setExpiryDate(date)}
/>
</div>
<p className="mt-1 text-xs text-slate-500">
{t("environments.surveys.summary.expiry_date_description")}
</p>
</div>
{/* Generate Button */}
<Button
onClick={handleGenerateLinks}
disabled={isButtonDisabled}
loading={isGenerating}
className="w-fit">
<DownloadIcon className="mr-2 h-4 w-4" />
{buttonText}
</Button>
</div>
<hr />
{/* Info Box */}
<Alert variant="info" size="small">
<AlertTitle>{t("environments.surveys.summary.personal_links_work_with_segments")}</AlertTitle>
<AlertButton>
<Link
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting#segment-configuration"
target="_blank"
rel="noopener noreferrer">
{t("common.learn_more")}
</Link>
</AlertButton>
</Alert>
</div>
{/* Info Box */}
<DocumentationLinks
links={[
{
title: t("environments.surveys.share.personal_links.work_with_segments"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting#segment-configuration",
},
]}
/>
</TabContainer>
</FormProvider>
);
};

View File

@@ -1,6 +1,6 @@
"use client";
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/TabContainer";
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
import { getQRCodeOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/get-qr-code-options";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";

View File

@@ -1,52 +1,45 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { ShareViewType } from "../../types/share";
import { ShareView } from "./share-view";
// Mock child components
vi.mock("./AppTab", () => ({
vi.mock("./app-tab", () => ({
AppTab: () => <div data-testid="app-tab">AppTab Content</div>,
}));
vi.mock("./EmailTab", () => ({
vi.mock("./email-tab", () => ({
EmailTab: (props: { surveyId: string; email: string }) => (
<div data-testid="email-tab">
EmailTab Content for {props.surveyId} with {props.email}
</div>
),
}));
vi.mock("./LinkTab", () => ({
LinkTab: (props: { survey: any; surveyUrl: string }) => (
<div data-testid="link-tab">
LinkTab Content for {props.survey.id} at {props.surveyUrl}
</div>
),
}));
vi.mock("./QRCodeTab", () => ({
vi.mock("./qr-code-tab", () => ({
QRCodeTab: (props: { surveyUrl: string }) => (
<div data-testid="qr-code-tab">QRCodeTab Content for {props.surveyUrl}</div>
),
}));
vi.mock("./WebsiteTab", () => ({
WebsiteTab: (props: { surveyUrl: string; environmentId: string }) => (
<div data-testid="website-tab">
WebsiteTab Content for {props.surveyUrl} in {props.environmentId}
</div>
),
}));
vi.mock("./WebsiteEmbedTab", () => ({
vi.mock("./website-embed-tab", () => ({
WebsiteEmbedTab: (props: { surveyUrl: string }) => (
<div data-testid="website-embed-tab">WebsiteEmbedTab Content for {props.surveyUrl}</div>
),
}));
vi.mock("./DynamicPopupTab", () => ({
vi.mock("./dynamic-popup-tab", () => ({
DynamicPopupTab: (props: { environmentId: string; surveyId: string }) => (
<div data-testid="dynamic-popup-tab">
DynamicPopupTab Content for {props.surveyId} in {props.environmentId}
</div>
),
}));
vi.mock("./TabContainer", () => ({
vi.mock("./tab-container", () => ({
TabContainer: (props: { children: React.ReactNode; title: string; description: string }) => (
<div data-testid="tab-container">
<div data-testid="tab-title">{props.title}</div>
@@ -64,6 +57,20 @@ vi.mock("./personal-links-tab", () => ({
),
}));
vi.mock("./anonymous-links-tab", () => ({
AnonymousLinksTab: (props: {
survey: TSurvey;
surveyUrl: string;
publicDomain: string;
setSurveyUrl: (url: string) => void;
locale: TUserLocale;
}) => (
<div data-testid="anonymous-links-tab">
AnonymousLinksTab Content for {props.survey.id} at {props.surveyUrl}
</div>
),
}));
vi.mock("@/modules/ui/components/upgrade-prompt", () => ({
UpgradePrompt: (props: { title: string; description: string }) => (
<div data-testid="upgrade-prompt">
@@ -81,25 +88,27 @@ vi.mock("@tolgee/react", () => ({
// Mock lucide-react
vi.mock("lucide-react", () => ({
CopyIcon: () => <div data-testid="copy-icon">CopyIcon</div>,
ArrowLeftIcon: () => <div data-testid="arrow-left-icon">ArrowLeftIcon</div>,
ArrowUpRightIcon: () => <div data-testid="arrow-up-right-icon">ArrowUpRightIcon</div>,
MailIcon: () => <div data-testid="mail-icon">MailIcon</div>,
LinkIcon: () => <div data-testid="link-icon">LinkIcon</div>,
GlobeIcon: () => <div data-testid="globe-icon">GlobeIcon</div>,
SmartphoneIcon: () => <div data-testid="smartphone-icon">SmartphoneIcon</div>,
CheckCircle2Icon: () => <div data-testid="check-circle-2-icon">CheckCircle2Icon</div>,
AlertCircle: ({ className }: { className?: string }) => (
<div className={className} data-testid="alert-circle">
AlertCircle
AlertCircleIcon: ({ className }: { className?: string }) => (
<div className={className} data-testid="alert-circle-icon">
AlertCircleIcon
</div>
),
AlertTriangle: ({ className }: { className?: string }) => (
<div className={className} data-testid="alert-triangle">
AlertTriangle
AlertTriangleIcon: ({ className }: { className?: string }) => (
<div className={className} data-testid="alert-triangle-icon">
AlertTriangleIcon
</div>
),
Info: ({ className }: { className?: string }) => (
<div className={className} data-testid="info">
Info
InfoIcon: ({ className }: { className?: string }) => (
<div className={className} data-testid="info-icon">
InfoIcon
</div>
),
Download: ({ className }: { className?: string }) => (
@@ -169,13 +178,21 @@ vi.mock("@/lib/cn", () => ({
cn: (...args: any[]) => args.filter(Boolean).join(" "),
}));
const mockTabs = [
{ id: "email", label: "Email", icon: () => <div data-testid="email-tab-icon" /> },
{ id: "website-embed", label: "Website Embed", icon: () => <div data-testid="website-embed-tab-icon" /> },
{ id: "dynamic-popup", label: "Dynamic Popup", icon: () => <div data-testid="dynamic-popup-tab-icon" /> },
{ id: "link", label: "Link", icon: () => <div data-testid="link-tab-icon" /> },
{ id: "qr-code", label: "QR Code", icon: () => <div data-testid="qr-code-tab-icon" /> },
{ id: "app", label: "App", icon: () => <div data-testid="app-tab-icon" /> },
const mockTabs: Array<{ id: ShareViewType; label: string; icon: React.ElementType }> = [
{ id: ShareViewType.EMAIL, label: "Email", icon: () => <div data-testid="email-tab-icon" /> },
{
id: ShareViewType.WEBSITE_EMBED,
label: "Website Embed",
icon: () => <div data-testid="website-embed-tab-icon" />,
},
{
id: ShareViewType.DYNAMIC_POPUP,
label: "Dynamic Popup",
icon: () => <div data-testid="dynamic-popup-tab-icon" />,
},
{ id: ShareViewType.ANON_LINKS, label: "Anonymous Links", icon: () => <div data-testid="link-tab-icon" /> },
{ id: ShareViewType.QR_CODE, label: "QR Code", icon: () => <div data-testid="qr-code-tab-icon" /> },
{ id: ShareViewType.APP, label: "App", icon: () => <div data-testid="app-tab-icon" /> },
];
const mockSurveyLink = {
@@ -223,7 +240,7 @@ const mockSurveyWeb = {
const defaultProps = {
tabs: mockTabs,
activeId: "email",
activeId: ShareViewType.EMAIL,
setActiveId: vi.fn(),
environmentId: "env1",
survey: mockSurveyLink,
@@ -253,23 +270,23 @@ describe("ShareView", () => {
});
test("renders desktop tabs for link survey type", () => {
render(<ShareView {...defaultProps} survey={mockSurveyLink} />);
render(<ShareView {...defaultProps} survey={mockSurveyLink} activeId={ShareViewType.EMAIL} />);
// For link survey types, desktop sidebar should be rendered
const sidebarLabel = screen.getByText("Share via");
const sidebarLabel = screen.getByText("environments.surveys.share.share_view_title");
expect(sidebarLabel).toBeInTheDocument();
});
test("calls setActiveId when a tab is clicked (desktop)", async () => {
render(<ShareView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
render(<ShareView {...defaultProps} survey={mockSurveyLink} activeId={ShareViewType.EMAIL} />);
const websiteEmbedTabButton = screen.getByLabelText("Website Embed");
await userEvent.click(websiteEmbedTabButton);
expect(defaultProps.setActiveId).toHaveBeenCalledWith("website-embed");
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViewType.WEBSITE_EMBED);
});
test("renders EmailTab when activeId is 'email'", () => {
render(<ShareView {...defaultProps} activeId="email" />);
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
expect(screen.getByTestId("email-tab")).toBeInTheDocument();
expect(
screen.getByText(`EmailTab Content for ${defaultProps.survey.id} with ${defaultProps.email}`)
@@ -277,15 +294,13 @@ describe("ShareView", () => {
});
test("renders WebsiteEmbedTab when activeId is 'website-embed'", () => {
render(<ShareView {...defaultProps} activeId="website-embed" />);
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
render(<ShareView {...defaultProps} activeId={ShareViewType.WEBSITE_EMBED} />);
expect(screen.getByTestId("website-embed-tab")).toBeInTheDocument();
expect(screen.getByText(`WebsiteEmbedTab Content for ${defaultProps.surveyUrl}`)).toBeInTheDocument();
});
test("renders DynamicPopupTab when activeId is 'dynamic-popup'", () => {
render(<ShareView {...defaultProps} activeId="dynamic-popup" />);
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
render(<ShareView {...defaultProps} activeId={ShareViewType.DYNAMIC_POPUP} />);
expect(screen.getByTestId("dynamic-popup-tab")).toBeInTheDocument();
expect(
screen.getByText(
@@ -294,26 +309,26 @@ describe("ShareView", () => {
).toBeInTheDocument();
});
test("renders LinkTab when activeId is 'link'", () => {
render(<ShareView {...defaultProps} activeId="link" />);
expect(screen.getByTestId("link-tab")).toBeInTheDocument();
test("renders AnonymousLinksTab when activeId is 'anon-links'", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.ANON_LINKS} />);
expect(screen.getByTestId("anonymous-links-tab")).toBeInTheDocument();
expect(
screen.getByText(`LinkTab Content for ${defaultProps.survey.id} at ${defaultProps.surveyUrl}`)
screen.getByText(`AnonymousLinksTab Content for ${defaultProps.survey.id} at ${defaultProps.surveyUrl}`)
).toBeInTheDocument();
});
test("renders QRCodeTab when activeId is 'qr-code'", () => {
render(<ShareView {...defaultProps} activeId="qr-code" />);
render(<ShareView {...defaultProps} activeId={ShareViewType.QR_CODE} />);
expect(screen.getByTestId("qr-code-tab")).toBeInTheDocument();
});
test("renders AppTab when activeId is 'app'", () => {
render(<ShareView {...defaultProps} activeId="app" />);
render(<ShareView {...defaultProps} activeId={ShareViewType.APP} />);
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
});
test("renders PersonalLinksTab when activeId is 'personal-links'", () => {
render(<ShareView {...defaultProps} activeId="personal-links" />);
render(<ShareView {...defaultProps} activeId={ShareViewType.PERSONAL_LINKS} />);
expect(screen.getByTestId("personal-links-tab")).toBeInTheDocument();
expect(
screen.getByText(
@@ -323,7 +338,7 @@ describe("ShareView", () => {
});
test("calls setActiveId when a responsive tab is clicked", async () => {
render(<ShareView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
render(<ShareView {...defaultProps} survey={mockSurveyLink} activeId={ShareViewType.EMAIL} />);
// Get responsive buttons - these are Button components containing icons
const responsiveButtons = screen.getAllByTestId("website-embed-tab-icon");
@@ -337,12 +352,12 @@ describe("ShareView", () => {
if (responsiveButton) {
await userEvent.click(responsiveButton);
expect(defaultProps.setActiveId).toHaveBeenCalledWith("website-embed");
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViewType.WEBSITE_EMBED);
}
});
test("applies active styles to the active tab (desktop)", () => {
render(<ShareView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
render(<ShareView {...defaultProps} survey={mockSurveyLink} activeId={ShareViewType.EMAIL} />);
const emailTabButton = screen.getByLabelText("Email");
expect(emailTabButton).toHaveClass("bg-slate-100");
@@ -355,7 +370,7 @@ describe("ShareView", () => {
});
test("applies active styles to the active tab (responsive)", () => {
render(<ShareView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
render(<ShareView {...defaultProps} survey={mockSurveyLink} activeId={ShareViewType.EMAIL} />);
// Get responsive buttons - these are Button components with ghost variant
const responsiveButtons = screen.getAllByTestId("email-tab-icon");

View File

@@ -1,8 +1,8 @@
"use client";
import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/DynamicPopupTab";
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/TabContainer";
import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab";
import { QRCodeTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab";
import { ShareViewType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
import {
@@ -23,16 +23,16 @@ import { useEffect, useState } from "react";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { AppTab } from "./AppTab";
import { EmailTab } from "./EmailTab";
import { LinkTab } from "./LinkTab";
import { WebsiteEmbedTab } from "./WebsiteEmbedTab";
import { AnonymousLinksTab } from "./anonymous-links-tab";
import { AppTab } from "./app-tab";
import { EmailTab } from "./email-tab";
import { PersonalLinksTab } from "./personal-links-tab";
import { WebsiteEmbedTab } from "./website-embed-tab";
interface ShareViewProps {
tabs: Array<{ id: string; label: string; icon: React.ElementType }>;
activeId: string;
setActiveId: React.Dispatch<React.SetStateAction<string>>;
tabs: Array<{ id: ShareViewType; label: string; icon: React.ElementType }>;
activeId: ShareViewType;
setActiveId: React.Dispatch<React.SetStateAction<ShareViewType>>;
environmentId: string;
survey: TSurvey;
email: string;
@@ -60,8 +60,8 @@ export const ShareView = ({
isContactsEnabled,
isFormbricksCloud,
}: ShareViewProps) => {
const [isLargeScreen, setIsLargeScreen] = useState(true);
const { t } = useTranslate();
const [isLargeScreen, setIsLargeScreen] = useState(true);
useEffect(() => {
const checkScreenSize = () => {
@@ -77,27 +77,15 @@ export const ShareView = ({
const renderActiveTab = () => {
switch (activeId) {
case "email":
case ShareViewType.EMAIL:
return <EmailTab surveyId={survey.id} email={email} />;
case "website-embed":
case ShareViewType.WEBSITE_EMBED:
return <WebsiteEmbedTab surveyUrl={surveyUrl} />;
case ShareViewType.DYNAMIC_POPUP:
return <DynamicPopupTab environmentId={environmentId} surveyId={survey.id} />;
case ShareViewType.ANON_LINKS:
return (
<TabContainer
title={t("environments.surveys.share.embed_on_website.title")}
description={t("environments.surveys.share.embed_on_website.description")}>
<WebsiteEmbedTab surveyUrl={surveyUrl} />
</TabContainer>
);
case "dynamic-popup":
return (
<TabContainer
title={t("environments.surveys.share.dynamic_popup.title")}
description={t("environments.surveys.share.dynamic_popup.description")}>
<DynamicPopupTab environmentId={environmentId} surveyId={survey.id} />
</TabContainer>
);
case "link":
return (
<LinkTab
<AnonymousLinksTab
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
@@ -105,11 +93,11 @@ export const ShareView = ({
locale={locale}
/>
);
case "qr-code":
return <QRCodeTab surveyUrl={surveyUrl} />;
case "app":
case ShareViewType.APP:
return <AppTab />;
case "personal-links":
case ShareViewType.QR_CODE:
return <QRCodeTab surveyUrl={surveyUrl} />;
case ShareViewType.PERSONAL_LINKS:
return (
<PersonalLinksTab
segments={segments}
@@ -140,7 +128,9 @@ export const ShareView = ({
<SidebarContent className="h-full border-r border-slate-200 bg-white p-4">
<SidebarGroup className="p-0">
<SidebarGroupLabel>
<Small className="text-xs text-slate-500">Share via</Small>
<Small className="text-xs text-slate-500">
{t("environments.surveys.share.share_view_title")}
</Small>
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="flex flex-col gap-1">

View File

@@ -1,7 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TabContainer } from "./TabContainer";
import { TabContainer } from "./tab-container";
// Mock components
vi.mock("@/modules/ui/components/typography", () => ({

View File

@@ -9,7 +9,7 @@ interface TabContainerProps {
export const TabContainer = ({ title, description, children }: TabContainerProps) => {
return (
<div className="flex h-full grow flex-col items-start space-y-4">
<div>
<div className="pb-2">
<H3>{title}</H3>
<Small color="muted" margin="headerDescription">
{description}

View File

@@ -2,7 +2,7 @@ import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WebsiteEmbedTab } from "./WebsiteEmbedTab";
import { WebsiteEmbedTab } from "./website-embed-tab";
// Mock components
vi.mock("@/modules/ui/components/advanced-option-toggle", () => ({
@@ -59,7 +59,7 @@ vi.mock("@/modules/ui/components/code-block", () => ({
}) => (
<div data-testid="code-block">
<span data-testid="language">{props.language}</span>
<span data-testid="show-copy">{props.showCopyToClipboard.toString()}</span>
<span data-testid="show-copy">{props.showCopyToClipboard?.toString() || "false"}</span>
{props.noMargin && <span data-testid="no-margin">true</span>}
<pre>{props.children}</pre>
</div>
@@ -157,7 +157,7 @@ describe("WebsiteEmbedTab", () => {
);
const toast = await import("react-hot-toast");
expect(toast.default.success).toHaveBeenCalledWith(
"environments.surveys.summary.embed_code_copied_to_clipboard"
"environments.surveys.share.embed_on_website.embed_code_copied_to_clipboard"
);
});
@@ -185,8 +185,8 @@ describe("WebsiteEmbedTab", () => {
render(<WebsiteEmbedTab {...defaultProps} />);
const toggle = screen.getByTestId("advanced-option-toggle");
expect(toggle).toHaveTextContent("environments.surveys.summary.embed_mode");
expect(toggle).toHaveTextContent("environments.surveys.summary.embed_mode_description");
expect(toggle).toHaveTextContent("environments.surveys.share.embed_on_website.embed_mode");
expect(toggle).toHaveTextContent("environments.surveys.share.embed_on_website.embed_mode_description");
expect(screen.getByTestId("custom-container-class")).toHaveTextContent("p-0");
});
});

View File

@@ -7,6 +7,7 @@ import { useTranslate } from "@tolgee/react";
import { CopyIcon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { TabContainer } from "./tab-container";
interface WebsiteEmbedTabProps {
surveyUrl: string;
@@ -24,22 +25,19 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
</div>`;
return (
<>
<div className="prose prose-slate max-w-full">
<CodeBlock
customCodeClass="text-sm h-48 overflow-y-scroll"
language="html"
showCopyToClipboard={false}
noMargin>
{iframeCode}
</CodeBlock>
</div>
<TabContainer
title={t("environments.surveys.share.embed_on_website.title")}
description={t("environments.surveys.share.embed_on_website.description")}>
<CodeBlock language="html" noMargin>
{iframeCode}
</CodeBlock>
<AdvancedOptionToggle
htmlId="enableEmbedMode"
isChecked={embedModeEnabled}
onToggle={setEmbedModeEnabled}
title={t("environments.surveys.summary.embed_mode")}
description={t("environments.surveys.summary.embed_mode_description")}
title={t("environments.surveys.share.embed_on_website.embed_mode")}
description={t("environments.surveys.share.embed_on_website.embed_mode_description")}
customContainerClass="p-0"
/>
<Button
@@ -47,11 +45,11 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
aria-label={t("common.copy_code")}
onClick={() => {
navigator.clipboard.writeText(iframeCode);
toast.success(t("environments.surveys.summary.embed_code_copied_to_clipboard"));
toast.success(t("environments.surveys.share.embed_on_website.embed_code_copied_to_clipboard"));
}}>
{t("common.copy_code")}
<CopyIcon />
</Button>
</>
</TabContainer>
);
};

View File

@@ -0,0 +1,10 @@
export enum ShareViewType {
ANON_LINKS = "anon-links",
PERSONAL_LINKS = "personal-links",
EMAIL = "email",
WEBPAGE = "webpage",
APP = "app",
WEBSITE_EMBED = "website-embed",
DYNAMIC_POPUP = "dynamic-popup",
QR_CODE = "qr-code",
}

View File

@@ -557,8 +557,8 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
return modifiedSurvey;
} catch (error) {
logger.error(error, "Error updating survey");
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error updating survey");
throw new DatabaseError(error.message);
}

View File

@@ -1,4 +1,4 @@
import { describe, expect, test, vi } from "vitest";
import { describe, expect, test } from "vitest";
import {
convertDateString,
convertDateTimeString,

View File

@@ -326,7 +326,6 @@
"response": "Antwort",
"responses": "Antworten",
"restart": "Neustart",
"retry": "Erneut versuchen",
"role": "Rolle",
"role_organization": "Rolle (Organisation)",
"saas": "SaaS",
@@ -1250,6 +1249,8 @@
"add_description": "Beschreibung hinzufügen",
"add_ending": "Abschluss hinzufügen",
"add_ending_below": "Abschluss unten hinzufügen",
"add_fallback": "Hinzufügen",
"add_fallback_placeholder": "Hinzufügen eines Platzhalters, der angezeigt wird, wenn die Frage übersprungen wird:",
"add_hidden_field_id": "Verstecktes Feld ID hinzufügen",
"add_highlight_border": "Rahmen hinzufügen",
"add_highlight_border_description": "Füge deiner Umfragekarte einen äußeren Rahmen hinzu.",
@@ -1388,6 +1389,7 @@
"error_saving_changes": "Fehler beim Speichern der Änderungen",
"even_after_they_submitted_a_response_e_g_feedback_box": "Sogar nachdem sie eine Antwort eingereicht haben (z.B. Feedback-Box)",
"everyone": "Jeder",
"fallback_for": "Ersatz für",
"fallback_missing": "Fehlender Fallback",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
"field_name_eg_score_price": "Feldname z.B. Punktzahl, Preis",
@@ -1699,14 +1701,96 @@
"results_unpublished_successfully": "Ergebnisse wurden nicht erfolgreich veröffentlicht.",
"search_by_survey_name": "Nach Umfragenamen suchen",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Wenn Sie eine Einmal-ID nicht verschlüsseln, funktioniert jeder Wert für „suid=...“ für eine Antwort.",
"custom_single_use_id_title": "Sie können im URL beliebige Werte als Einmal-ID festlegen.",
"custom_start_point": "Benutzerdefinierter Startpunkt",
"data_prefilling": "Daten-Prefilling",
"description": "Antworten, die von diesen Links kommen, werden anonym",
"disable_multi_use_link_modal_button": "Mehrfach verwendeten Link deaktivieren",
"disable_multi_use_link_modal_description": "Das Deaktivieren des Mehrfachnutzungslinks verhindert, dass jemand mithilfe des Links eine Antwort einreichen kann.",
"disable_multi_use_link_modal_description_one": "Das Deaktivieren des Mehrfachnutzungslinks verhindert, dass jemand mithilfe des Links eine Antwort einreichen kann.",
"disable_multi_use_link_modal_description_subtext": "Dies wird auch alle aktiven Einbettungen auf Websites, E-Mails, sozialen Medien und QR-Codes stören, die diesen Mehrfachnutzungslink verwenden.",
"disable_multi_use_link_modal_description_two": " Dies wird auch alle aktiven Einbettungen auf Websites, E-Mails, sozialen Medien und QR-Codes\n stören, die diesen Mehrfachnutzungslink verwenden.",
"disable_multi_use_link_modal_title": "Bist du sicher? Dies könnte aktive Einbettungen stören",
"disable_single_use_link_modal_button": "Einmalige Links deaktivieren",
"disable_single_use_link_modal_description": "Wenn Sie Einweglinks geteilt haben, können die Teilnehmer nicht mehr auf die Umfrage antworten.",
"generate_and_download_links": "Links generieren und herunterladen",
"generate_links_error": "Einmalige Verlinkungen konnten nicht generiert werden. Bitte arbeiten Sie direkt mit der API.",
"multi_use_link": "Mehrfach verwendet",
"multi_use_link_description": "Sammle mehrere Antworten von anonymen Teilnehmern mit einem Link",
"multi_use_powers_other_channels_description": "Wenn du es deaktivierst, werden auch diese anderen Vertriebskanäle deaktiviert.",
"multi_use_powers_other_channels_title": "Dieser Link ermöglicht Einbettungen auf Websites, Einbettungen in E-Mails, Teilen in sozialen Medien und QR-Codes",
"multi_use_toggle_error": "Fehler beim Aktivieren der Mehrfachnutzung, bitte versuche es später erneut",
"nav_title": "Anonyme Links",
"number_of_links_empty": "Anzahl der Links erforderlich",
"number_of_links_label": "Anzahl der Links (1 - 5.000)",
"single_use_link": "Einmalige Links",
"single_use_link_description": "Erlaube nur eine Antwort pro Umfragelink.",
"single_use_links": "Einmalige Links",
"source_tracking": "Quellenverfolgung",
"title": "Teilen Sie Ihre Umfrage, um Antworten zu sammeln",
"url_encryption_description": "Nur deaktivieren, wenn Sie eine benutzerdefinierte Einmal-ID setzen müssen.",
"url_encryption_label": "Verschlüsselung der URL für einmalige Nutzung ID"
},
"dynamic_popup": {
"alert_button": "Umfrage bearbeiten",
"alert_description": "Diese Umfrage ist derzeit als Link-Umfrage konfiguriert, die dynamische Pop-ups nicht unterstützt. Sie können dies im Tab Einstellungen im Umfrage-Editor ändern.",
"alert_title": "Umfragen-Typ in In-App ändern",
"attribute_based_targeting": "Attributbasiertes Targeting",
"code_no_code_triggers": "Code- und No-Code-Auslöser",
"description": "Formbricks Umfragen können als Pop-up eingebettet werden, basierend auf der Benutzerinteraktion.",
"docs_title": "Mehr mit Zwischenumfragen tun",
"nav_title": "Dynamisch (Pop-up)",
"recontact_options": "Optionen zur erneuten Kontaktaufnahme",
"title": "Nutzer im Ablauf abfangen, um kontextualisiertes Feedback zu sammeln"
},
"embed_on_website": {
"description": "Formbricks-Umfragen können als statisches Element eingebettet werden.",
"embed_code_copied_to_clipboard": "Einbettungscode in die Zwischenablage kopiert!",
"embed_in_an_email": "In eine E-Mail einbetten",
"embed_in_app": "In App einbetten",
"embed_mode": "Einbettungsmodus",
"embed_mode_description": "Bette deine Umfrage mit einem minimalistischen Design ein, ohne Karten und Hintergrund.",
"nav_title": "Auf Website einbetten",
"title": "Binden Sie die Umfrage auf Ihrer Webseite ein"
}
},
"personal_links": {
"create_and_manage_segments": "Erstellen und verwalten Sie Ihre Segmente unter Kontakte > Segmente",
"create_single_use_links": "Single-Use Links erstellen",
"create_single_use_links_description": "Akzeptiere nur eine Antwort pro Link. So geht's.",
"description": "Erstellen Sie persönliche Links für ein Segment und ordnen Sie Umfrageantworten jedem Kontakt zu.",
"expiry_date_description": "Sobald der Link abläuft, kann der Empfänger nicht mehr auf die Umfrage antworten.",
"expiry_date_optional": "Ablaufdatum (optional)",
"generate_and_download_links": "Links generieren und herunterladen",
"generating_links": "Links werden generiert",
"generating_links_toast": "Links werden generiert, der Download startet in Kürze…",
"links_generated_success_toast": "Links erfolgreich generiert, Ihr Download beginnt in Kürze.",
"nav_title": "Persönliche Links",
"no_segments_available": "Keine Segmente verfügbar",
"select_segment": "Segment auswählen",
"title": "Maximieren Sie Erkenntnisse mit persönlichen Umfragelinks",
"upgrade_prompt_description": "Erstellen Sie persönliche Links für ein Segment und verknüpfen Sie Umfrageantworten mit jedem Kontakt.",
"upgrade_prompt_title": "Verwende persönliche Links mit einem höheren Plan",
"work_with_segments": "Persönliche Links funktionieren mit Segmenten."
},
"send_email": {
"copy_embed_code": "Einbettungscode kopieren",
"description": "Binden Sie Ihre Umfrage in eine E-Mail ein, um Antworten von Ihrem Publikum zu erhalten.",
"email_preview_tab": "E-Mail Vorschau",
"email_sent": "E-Mail gesendet!",
"email_subject_label": "Betreff",
"email_to_label": "An",
"embed_code_copied_to_clipboard": "Einbettungscode in die Zwischenablage kopiert!",
"embed_code_copied_to_clipboard_failed": "Kopieren fehlgeschlagen, bitte versuche es erneut",
"embed_code_tab": "Einbettungscode",
"formbricks_email_survey_preview": "Formbricks E-Mail-Umfrage Vorschau",
"nav_title": "E-Mail-Einbettung",
"send_preview": "Vorschau senden",
"send_preview_email": "Vorschau-E-Mail senden",
"title": "Binden Sie Ihre Umfrage in eine E-Mail ein"
},
"share_view_title": "Teilen über"
},
"summary": {
"added_filter_for_responses_where_answer_to_question": "Filter hinzugefügt für Antworten, bei denen die Antwort auf Frage {questionIdx} {filterComboBoxValue} - {filterValue} ist",
@@ -1715,6 +1799,22 @@
"all_responses_excel": "Alle Antworten (Excel)",
"all_time": "Gesamt",
"almost_there": "Fast geschafft! Installiere das Widget, um mit dem Empfang von Antworten zu beginnen.",
"anonymous_links": "Anonyme Links",
"anonymous_links.custom_start_point": "Benutzerdefinierter Startpunkt",
"anonymous_links.data_prefilling": "Daten-Prefilling",
"anonymous_links.docs_title": "Mehr mit Link-Umfragen tun",
"anonymous_links.multi_use_link": "Mehrfach verwendet",
"anonymous_links.multi_use_link_alert_description": "Wenn du es deaktivierst, werden auch diese anderen Vertriebskanäle deaktiviert.",
"anonymous_links.multi_use_link_alert_title": "Dieser Link ermöglicht Einbettungen auf Websites, Einbettungen in E-Mails, Teilen in sozialen Medien und QR-Codes",
"anonymous_links.multi_use_link_description": "Sammle mehrere Antworten von anonymen Teilnehmern mit einem Link",
"anonymous_links.single_use_link": "Einmaliger Link",
"anonymous_links.single_use_link_description": "Erlaube nur eine Antwort pro Umfragelink.",
"anonymous_links.single_use_link_encryption": "Verschlüsselung der URL für einmalige Nutzung ID",
"anonymous_links.single_use_link_encryption_alert_description": "Wenn Sie die Einmal-ID's nicht verschlüsseln, funktioniert jeder Wert für „suid=...“ für eine Antwort.",
"anonymous_links.single_use_link_encryption_description": "Nur deaktivieren, wenn Sie eine benutzerdefinierte Einmal-ID setzen müssen",
"anonymous_links.single_use_link_encryption_generate_and_download_links": "Links generieren und herunterladen",
"anonymous_links.single_use_link_encryption_number_of_links": "Anzahl der Links (1 - 5.000)",
"anonymous_links.source_tracking": "Quellenverfolgung",
"average": "Durchschnittlich",
"completed": "Abgeschlossen",
"completed_tooltip": "Anzahl der abgeschlossenen Umfragen.",

View File

@@ -326,7 +326,6 @@
"response": "Response",
"responses": "Responses",
"restart": "Restart",
"retry": "Retry",
"role": "Role",
"role_organization": "Role (Organization)",
"saas": "SaaS",
@@ -1250,6 +1249,8 @@
"add_description": "Add description",
"add_ending": "Add ending",
"add_ending_below": "Add ending below",
"add_fallback": "Add",
"add_fallback_placeholder": "Add a placeholder to show if the question gets skipped:",
"add_hidden_field_id": "Add hidden field ID",
"add_highlight_border": "Add highlight border",
"add_highlight_border_description": "Add an outer border to your survey card.",
@@ -1388,6 +1389,7 @@
"error_saving_changes": "Error saving changes",
"even_after_they_submitted_a_response_e_g_feedback_box": "Even after they submitted a response (e.g. Feedback Box)",
"everyone": "Everyone",
"fallback_for": "Fallback for ",
"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.",
"field_name_eg_score_price": "Field name e.g, score, price",
@@ -1699,14 +1701,96 @@
"results_unpublished_successfully": "Results unpublished successfully.",
"search_by_survey_name": "Search by survey name",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "If you dont encrypt single-use IDs, any value for “suid=...” works for one response.",
"custom_single_use_id_title": "You can set any value as single-use ID in the URL.",
"custom_start_point": "Custom start point",
"data_prefilling": "Data prefilling",
"description": "Responses coming from these links will be anonymous",
"disable_multi_use_link_modal_button": "Disable multi-use link",
"disable_multi_use_link_modal_description": "Disabling the multi-use link will prevent anyone to submit a response via the link.",
"disable_multi_use_link_modal_description_one": "Disabling the multi-use link will prevent anyone to submit a response via the link.",
"disable_multi_use_link_modal_description_subtext": "This will also break any active embeds on Websites, Emails, Social Media and QR codes that use this multi-use link.",
"disable_multi_use_link_modal_description_two": " This will also break any active embeds on Websites, Emails, Social Media and QR codes that use\n this multi-use link.",
"disable_multi_use_link_modal_title": "Are you sure? This can break active embeddings",
"disable_single_use_link_modal_button": "Disable single-use links",
"disable_single_use_link_modal_description": "If you shared single-use links, participants will not be able to respond to the survey any longer.",
"generate_and_download_links": "Generate & download links",
"generate_links_error": "Single use links could not get generated. Please work directly with the API",
"multi_use_link": "Multi-use link",
"multi_use_link_description": "Collect multiple responses from anonymous respondents with one link.",
"multi_use_powers_other_channels_description": "If you disable it, these other distribution channels will also get disabled.",
"multi_use_powers_other_channels_title": "This link powers Website embeds, Email embeds, Social media sharing and QR codes.",
"multi_use_toggle_error": "Error enabling multi-use links, please try again later",
"nav_title": "Anonymous links",
"number_of_links_empty": "Number of links is required",
"number_of_links_label": "Number of links (1 - 5,000)",
"single_use_link": "Single-use links",
"single_use_link_description": "Allow only one response per survey link.",
"single_use_links": "Single-use links",
"source_tracking": "Source tracking",
"title": "Share your survey to gather responses",
"url_encryption_description": "Only disable if you need to set a custom single-use ID.",
"url_encryption_label": "URL encryption of single-use ID"
},
"dynamic_popup": {
"alert_button": "Edit survey",
"alert_description": "This survey is currently configured as a link survey, which does not support dynamic pop-ups. You can change this in the settings tab of the survey editor.",
"alert_title": "Change survey type to in-app",
"attribute_based_targeting": "Attribute-based targeting",
"code_no_code_triggers": "Code and no code triggers",
"description": "Formbricks surveys can be embedded as a pop up, based on user interaction.",
"docs_title": "Do more with intercept surveys",
"nav_title": "Dynamic (Pop-up)",
"recontact_options": "Recontact options",
"title": "Intercept users in their flow to gather contextualized feedback"
},
"embed_on_website": {
"description": "Formbricks surveys can be embedded as a static element.",
"embed_code_copied_to_clipboard": "Embed code copied to clipboard!",
"embed_in_an_email": "Embed in an email",
"embed_in_app": "Embed in app",
"embed_mode": "Embed Mode",
"embed_mode_description": "Embed your survey with a minimalist design, discarding padding and background.",
"nav_title": "Website embed",
"title": "Embed the survey in your webpage"
}
},
"personal_links": {
"create_and_manage_segments": "Create and manage your Segments under Contacts > Segments",
"create_single_use_links": "Create single-use links",
"create_single_use_links_description": "Accept only one submission per link. Here is how.",
"description": "Generate personal links for a segment and match survey responses to each contact.",
"expiry_date_description": "Once the link expires, the recipient cannot respond to survey any longer.",
"expiry_date_optional": "Expiry date (optional)",
"generate_and_download_links": "Generate & download links",
"generating_links": "Generating links",
"generating_links_toast": "Generating links, download will start soon…",
"links_generated_success_toast": "Links generated successfully, your download will start soon.",
"nav_title": "Personal links",
"no_segments_available": "No segments available",
"select_segment": "Select segment",
"title": "Maximize insights with personal survey links",
"upgrade_prompt_description": "Generate personal links for a segment and link survey responses to each contact.",
"upgrade_prompt_title": "Use personal links with a higher plan",
"work_with_segments": "Personal links work with segments."
},
"send_email": {
"copy_embed_code": "Copy embed code",
"description": "Embed your survey in an email to get responses from your audience.",
"email_preview_tab": "Email Preview",
"email_sent": "Email sent!",
"email_subject_label": "Subject",
"email_to_label": "To",
"embed_code_copied_to_clipboard": "Embed code copied to clipboard!",
"embed_code_copied_to_clipboard_failed": "Copy failed, please try again",
"embed_code_tab": "Embed Code",
"formbricks_email_survey_preview": "Formbricks Email Survey Preview",
"nav_title": "Email embed",
"send_preview": "Send preview",
"send_preview_email": "Send preview email",
"title": "Embed your survey in an email"
},
"share_view_title": "Share via"
},
"summary": {
"added_filter_for_responses_where_answer_to_question": "Added filter for responses where answer to question {questionIdx} is {filterComboBoxValue} - {filterValue} ",
@@ -1715,6 +1799,22 @@
"all_responses_excel": "All responses (Excel)",
"all_time": "All time",
"almost_there": "Almost there! Install widget to start receiving responses.",
"anonymous_links": "Anonymous links",
"anonymous_links.custom_start_point": "Custom start point",
"anonymous_links.data_prefilling": "Data prefilling",
"anonymous_links.docs_title": "Do more with link surveys",
"anonymous_links.multi_use_link": "Multi-use link",
"anonymous_links.multi_use_link_alert_description": "If you disable it, these other distribution channels will also get disabled",
"anonymous_links.multi_use_link_alert_title": "This link powers Website embeds, Email embeds, Social media sharing and QR codes",
"anonymous_links.multi_use_link_description": "Collect multiple responses from anonymous respondents with one link",
"anonymous_links.single_use_link": "Single-use link",
"anonymous_links.single_use_link_description": "Allow only one response per survey link",
"anonymous_links.single_use_link_encryption": "URL encryption of single-use ID",
"anonymous_links.single_use_link_encryption_alert_description": "If you dont encrypt single-use IDs, any value for “suid=...” works for one response",
"anonymous_links.single_use_link_encryption_description": "Only disable if you need to set a custom single-use ID",
"anonymous_links.single_use_link_encryption_generate_and_download_links": "Generate & download links",
"anonymous_links.single_use_link_encryption_number_of_links": "Number of links (1 - 5,000)",
"anonymous_links.source_tracking": "Source tracking",
"average": "Average",
"completed": "Completed",
"completed_tooltip": "Number of times the survey has been completed.",

View File

@@ -326,7 +326,6 @@
"response": "Réponse",
"responses": "Réponses",
"restart": "Redémarrer",
"retry": "Réessayer",
"role": "Rôle",
"role_organization": "Rôle (Organisation)",
"saas": "SaaS",
@@ -1250,6 +1249,8 @@
"add_description": "Ajouter une description",
"add_ending": "Ajouter une fin",
"add_ending_below": "Ajouter une fin ci-dessous",
"add_fallback": "Ajouter",
"add_fallback_placeholder": "Ajouter un espace réservé pour montrer si la question est ignorée :",
"add_hidden_field_id": "Ajouter un champ caché ID",
"add_highlight_border": "Ajouter une bordure de surlignage",
"add_highlight_border_description": "Ajoutez une bordure extérieure à votre carte d'enquête.",
@@ -1388,6 +1389,7 @@
"error_saving_changes": "Erreur lors de l'enregistrement des modifications",
"even_after_they_submitted_a_response_e_g_feedback_box": "Même après avoir soumis une réponse (par exemple, la boîte de feedback)",
"everyone": "Tout le monde",
"fallback_for": "Solution de repli pour ",
"fallback_missing": "Fallback manquant",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
"field_name_eg_score_price": "Nom du champ par exemple, score, prix",
@@ -1699,14 +1701,96 @@
"results_unpublished_successfully": "Résultats publiés avec succès.",
"search_by_survey_name": "Recherche par nom d'enquête",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Si vous nencryptez pas les identifiants à usage unique, toute valeur pour « suid=... » fonctionne pour une seule réponse",
"custom_single_use_id_title": "Vous pouvez définir n'importe quelle valeur comme identifiant à usage unique dans l'URL.",
"custom_start_point": "Point de départ personnalisé",
"data_prefilling": "Préremplissage des données",
"description": "Les réponses provenant de ces liens seront anonymes",
"disable_multi_use_link_modal_button": "Désactiver le lien multi-usage",
"disable_multi_use_link_modal_description": "La désactivation du lien multi-usage empêchera quiconque de soumettre une réponse via le lien.",
"disable_multi_use_link_modal_description_one": "La désactivation du lien multi-usage empêchera quiconque de soumettre une réponse via le lien.",
"disable_multi_use_link_modal_description_subtext": "Cela cassera également toutes les intégrations actives sur les sites Web, les emails, les réseaux sociaux et les codes QR qui utilisent ce lien multi-usage.",
"disable_multi_use_link_modal_description_two": "Cela cassera également toutes les intégrations actives sur les sites web, les emails, les réseaux sociaux et les codes QR qui utilisent\nce lien multi-usage.",
"disable_multi_use_link_modal_title": "Êtes-vous sûr ? Cela peut casser les intégrations actives.",
"disable_single_use_link_modal_button": "Désactiver les liens à usage unique",
"disable_single_use_link_modal_description": "Si vous avez partagé des liens à usage unique, les participants ne pourront plus répondre au sondage.",
"generate_and_download_links": "Générer et télécharger les liens",
"generate_links_error": "Les liens à usage unique n'ont pas pu être générés. Veuillez travailler directement avec l'API",
"multi_use_link": "Lien multi-usage",
"multi_use_link_description": "Recueillir plusieurs réponses de répondants anonymes avec un seul lien.",
"multi_use_powers_other_channels_description": "Si vous le désactivez, ces autres canaux de distribution seront également désactivés.",
"multi_use_powers_other_channels_title": "Ce lien alimente les intégrations du site Web, les intégrations de courrier électronique, le partage sur les réseaux sociaux et les codes QR.",
"multi_use_toggle_error": "Erreur lors de l'activation des liens à usage multiple, veuillez réessayer plus tard",
"nav_title": "Liens anonymes",
"number_of_links_empty": "Le nombre de liens est requis",
"number_of_links_label": "Nombre de liens (1 - 5,000)",
"single_use_link": "Liens à usage unique",
"single_use_link_description": "Autoriser uniquement une réponse par lien d'enquête",
"single_use_links": "Liens à usage unique",
"source_tracking": "Suivi des sources",
"title": "Partagez votre enquête pour recueillir des réponses",
"url_encryption_description": "Désactiver seulement si vous devez définir un identifiant unique personnalisé",
"url_encryption_label": "Cryptage de l'identifiant à usage unique dans l'URL"
},
"dynamic_popup": {
"alert_button": "Modifier enquête",
"alert_description": "Ce sondage est actuellement configuré comme un sondage de lien, qui ne prend pas en charge les pop-ups dynamiques. Vous pouvez le modifier dans l'onglet des paramètres de l'éditeur de sondage.",
"alert_title": "Changer le type d'enquête en application intégrée",
"attribute_based_targeting": "Ciblage basé sur des attributs",
"code_no_code_triggers": "Déclencheurs avec et sans code",
"description": "Les enquêtes Formbricks peuvent être intégrées sous forme de pop-up, en fonction de l'interaction de l'utilisateur.",
"docs_title": "Faites plus avec les enquêtes d'interception",
"nav_title": "Dynamique (Pop-up)",
"recontact_options": "Options de recontact",
"title": "Interceptez les utilisateurs dans leur flux pour recueillir des retours contextualisés"
},
"embed_on_website": {
"description": "Les enquêtes Formbricks peuvent être intégrées comme élément statique.",
"embed_code_copied_to_clipboard": "Code d'intégration copié dans le presse-papiers !",
"embed_in_an_email": "Inclure dans un e-mail",
"embed_in_app": "Intégrer dans l'application",
"embed_mode": "Mode d'intégration",
"embed_mode_description": "Intégrez votre enquête avec un design minimaliste, en supprimant les marges et l'arrière-plan.",
"nav_title": "Incorporer sur le site web",
"title": "Intégrez le sondage sur votre page web"
}
},
"personal_links": {
"create_and_manage_segments": "Créez et gérez vos Segments sous Contacts > Segments",
"create_single_use_links": "Créer des liens à usage unique",
"create_single_use_links_description": "Acceptez uniquement une soumission par lien. Voici comment.",
"description": "Générez des liens personnels pour un segment et associez les réponses du sondage à chaque contact.",
"expiry_date_description": "Une fois le lien expiré, le destinataire ne peut plus répondre au sondage.",
"expiry_date_optional": "Date d'expiration (facultatif)",
"generate_and_download_links": "Générer et télécharger les liens",
"generating_links": "Génération de liens",
"generating_links_toast": "Génération des liens, le téléchargement commencera bientôt…",
"links_generated_success_toast": "Liens générés avec succès, votre téléchargement commencera bientôt.",
"nav_title": "Liens personnels",
"no_segments_available": "Aucun segment disponible",
"select_segment": "Sélectionner le segment",
"title": "Maximisez les insights avec des liens d'enquête personnels",
"upgrade_prompt_description": "Générez des liens personnels pour un segment et associez les réponses du sondage à chaque contact.",
"upgrade_prompt_title": "Utilisez des liens personnels avec un plan supérieur",
"work_with_segments": "Les liens personnels fonctionnent avec les segments."
},
"send_email": {
"copy_embed_code": "Copier le code d'intégration",
"description": "Intégrez votre sondage dans un email pour obtenir des réponses de votre audience.",
"email_preview_tab": "Aperçu de l'email",
"email_sent": "Email envoyé !",
"email_subject_label": "Sujet",
"email_to_label": "à",
"embed_code_copied_to_clipboard": "Code d'intégration copié dans le presse-papiers !",
"embed_code_copied_to_clipboard_failed": "Échec de la copie, veuillez réessayer",
"embed_code_tab": "Code d'intégration",
"formbricks_email_survey_preview": "Aperçu de l'enquête par e-mail Formbricks",
"nav_title": "Email intégré",
"send_preview": "Envoyer un aperçu",
"send_preview_email": "Envoyer un e-mail d'aperçu",
"title": "Intégrez votre sondage dans un e-mail"
},
"share_view_title": "Partager par"
},
"summary": {
"added_filter_for_responses_where_answer_to_question": "Filtre ajouté pour les réponses où la réponse à la question '{'questionIdx'}' est '{'filterComboBoxValue'}' - '{'filterValue'}' ",
@@ -1715,6 +1799,22 @@
"all_responses_excel": "Tous les réponses (Excel)",
"all_time": "Tout le temps",
"almost_there": "Presque là ! Installez le widget pour commencer à recevoir des réponses.",
"anonymous_links": "Liens anonymes",
"anonymous_links.custom_start_point": "Point de départ personnalisé",
"anonymous_links.data_prefilling": "Préremplissage des données",
"anonymous_links.docs_title": "Faites plus avec les sondages par lien",
"anonymous_links.multi_use_link": "Lien multi-usage",
"anonymous_links.multi_use_link_alert_description": "Si vous le désactivez, ces autres canaux de distribution seront également désactivés",
"anonymous_links.multi_use_link_alert_title": "Ce lien alimente les intégrations du site Web, les intégrations de courrier électronique, le partage sur les réseaux sociaux et les codes QR",
"anonymous_links.multi_use_link_description": "Recueillir plusieurs réponses de répondants anonymes avec un seul lien",
"anonymous_links.single_use_link": "Lien à usage unique",
"anonymous_links.single_use_link_description": "Autoriser uniquement une réponse par lien d'enquête",
"anonymous_links.single_use_link_encryption": "Cryptage de l'identifiant à usage unique dans l'URL",
"anonymous_links.single_use_link_encryption_alert_description": "Si vous nencryptez pas les identifiants à usage unique, toute valeur pour « suid=... » fonctionne pour une seule réponse",
"anonymous_links.single_use_link_encryption_description": "Désactiver seulement si vous devez définir un identifiant unique personnalisé",
"anonymous_links.single_use_link_encryption_generate_and_download_links": "Générer et télécharger les liens",
"anonymous_links.single_use_link_encryption_number_of_links": "Nombre de liens (1 - 5,000)",
"anonymous_links.source_tracking": "Suivi des sources",
"average": "Moyenne",
"completed": "Terminé",
"completed_tooltip": "Nombre de fois que l'enquête a été complétée.",

View File

@@ -326,7 +326,6 @@
"response": "Resposta",
"responses": "Respostas",
"restart": "Reiniciar",
"retry": "Tentar novamente",
"role": "Rolê",
"role_organization": "Função (Organização)",
"saas": "SaaS",
@@ -1250,6 +1249,8 @@
"add_description": "Adicionar Descrição",
"add_ending": "Adicionar final",
"add_ending_below": "Adicione o final abaixo",
"add_fallback": "Adicionar",
"add_fallback_placeholder": "Adicionar um texto padrão para mostrar se a pergunta for ignorada:",
"add_hidden_field_id": "Adicionar campo oculto ID",
"add_highlight_border": "Adicionar borda de destaque",
"add_highlight_border_description": "Adicione uma borda externa ao seu cartão de pesquisa.",
@@ -1388,6 +1389,7 @@
"error_saving_changes": "Erro ao salvar alterações",
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de eles enviarem uma resposta (por exemplo, Caixa de Feedback)",
"everyone": "Todo mundo",
"fallback_for": "Alternativa para",
"fallback_missing": "Faltando alternativa",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
"field_name_eg_score_price": "Nome do campo, por exemplo, pontuação, preço",
@@ -1699,14 +1701,96 @@
"results_unpublished_successfully": "Resultados não publicados com sucesso.",
"search_by_survey_name": "Buscar pelo nome da pesquisa",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Se você não criptografar IDs de uso único, qualquer valor para “suid=...” funciona para uma resposta",
"custom_single_use_id_title": "Você pode definir qualquer valor como ID de uso único na URL.",
"custom_start_point": "Ponto de início personalizado",
"data_prefilling": "preenchimento automático de dados",
"description": "Respostas vindas desses links serão anônimas",
"disable_multi_use_link_modal_button": "Desativar link de uso múltiplo",
"disable_multi_use_link_modal_description": "Desativar o link de uso múltiplo impedirá que alguém envie uma resposta por meio do link.",
"disable_multi_use_link_modal_description_one": "Desativar o link de uso múltiplo impedirá que alguém envie uma resposta por meio do link.",
"disable_multi_use_link_modal_description_subtext": "Também quebrará quaisquer incorporações ativas em Sites, Emails, Mídias Sociais e códigos QR que usem esse link de uso múltiplo.",
"disable_multi_use_link_modal_description_two": "Isso também quebrará quaisquer incorporações ativas em Sites, Emails, Mídias Sociais e códigos QR que usem\n esse link de uso múltiplo.",
"disable_multi_use_link_modal_title": "Tem certeza? Isso pode quebrar incorporações ativas",
"disable_single_use_link_modal_button": "Desativar links de uso único",
"disable_single_use_link_modal_description": "Se você compartilhou links de uso único, os participantes não poderão mais responder à pesquisa.",
"generate_and_download_links": "Gerar & baixar links",
"generate_links_error": "Não foi possível gerar links de uso único. Por favor, trabalhe diretamente com a API",
"multi_use_link": "Link de uso múltiplo",
"multi_use_link_description": "Coletar múltiplas respostas de respondentes anônimos com um link.",
"multi_use_powers_other_channels_description": "Se você desativar, esses outros canais de distribuição também serão desativados",
"multi_use_powers_other_channels_title": "Este link habilita incorporações em sites, incorporações em e-mails, compartilhamento em redes sociais e códigos QR",
"multi_use_toggle_error": "Erro ao habilitar links de uso múltiplo, tente novamente mais tarde",
"nav_title": "Links anônimos",
"number_of_links_empty": "O número de links é necessário",
"number_of_links_label": "Número de links (1 - 5.000)",
"single_use_link": "Links de uso único",
"single_use_link_description": "Permitir apenas uma resposta por link da pesquisa.",
"single_use_links": "Links de uso único",
"source_tracking": "rastreamento de origem",
"title": "Compartilhe sua pesquisa para coletar respostas",
"url_encryption_description": "Desative apenas se precisar definir um ID de uso único personalizado",
"url_encryption_label": "Criptografia de URL de ID de uso único"
},
"dynamic_popup": {
"alert_button": "Editar pesquisa",
"alert_description": "Esta pesquisa está atualmente configurada como uma pesquisa de link, o que não suporta pop-ups dinâmicos. Você pode alterar isso na aba de configurações do editor de pesquisas.",
"alert_title": "Alterar o tipo de pesquisa para dentro do app",
"attribute_based_targeting": "Segmentação baseada em atributos",
"code_no_code_triggers": "Gatilhos de código e sem código",
"description": "\"As pesquisas do Formbricks podem ser integradas como um pop-up, baseado na interação do usuário.\"",
"docs_title": "Faça mais com pesquisas de interceptação",
"nav_title": "Dinâmico (Pop-up)",
"recontact_options": "Opções de Recontato",
"title": "Intercepte os usuários em seu fluxo para coletar feedback contextualizado"
},
"embed_on_website": {
"description": "Os formulários Formbricks podem ser incorporados como um elemento estático.",
"embed_code_copied_to_clipboard": "Código incorporado copiado para a área de transferência!",
"embed_in_an_email": "Incorporar em um e-mail",
"embed_in_app": "Integrar no app",
"embed_mode": "Modo Embutido",
"embed_mode_description": "Incorpore sua pesquisa com um design minimalista, sem preenchimento e fundo.",
"nav_title": "Incorporar no site",
"title": "Incorporar a pesquisa na sua página da web"
}
},
"personal_links": {
"create_and_manage_segments": "Crie e gerencie seus Segmentos em Contatos > Segmentos",
"create_single_use_links": "Crie links de uso único",
"create_single_use_links_description": "Aceite apenas uma submissão por link. Aqui está como.",
"description": "Gerar links pessoais para um segmento e associar respostas de pesquisa a cada contato.",
"expiry_date_description": "Quando o link expirar, o destinatário não poderá mais responder à pesquisa.",
"expiry_date_optional": "Data de expiração (opcional)",
"generate_and_download_links": "Gerar & baixar links",
"generating_links": "Gerando links",
"generating_links_toast": "Gerando links, o download começará em breve…",
"links_generated_success_toast": "Links gerados com sucesso, o download começará em breve.",
"nav_title": "Links pessoais",
"no_segments_available": "Nenhum segmento disponível",
"select_segment": "Selecionar segmento",
"title": "Maximize insights com links de pesquisa personalizados",
"upgrade_prompt_description": "Gerar links pessoais para um segmento e vincular respostas de pesquisa a cada contato.",
"upgrade_prompt_title": "Use links pessoais com um plano superior",
"work_with_segments": "Links pessoais funcionam com segmentos."
},
"send_email": {
"copy_embed_code": "Copiar código incorporado",
"description": "Incorpore sua pesquisa em um e-mail para obter respostas do seu público.",
"email_preview_tab": "Prévia do Email",
"email_sent": "Email enviado!",
"email_subject_label": "Assunto",
"email_to_label": "Para",
"embed_code_copied_to_clipboard": "Código incorporado copiado para a área de transferência!",
"embed_code_copied_to_clipboard_failed": "Falha ao copiar, por favor, tente novamente",
"embed_code_tab": "Código de Incorporação",
"formbricks_email_survey_preview": "Prévia da Pesquisa por E-mail do Formbricks",
"nav_title": "Incorporação de Email",
"send_preview": "Enviar prévia",
"send_preview_email": "Enviar prévia de e-mail",
"title": "Incorpore sua pesquisa em um e-mail"
},
"share_view_title": "Compartilhar via"
},
"summary": {
"added_filter_for_responses_where_answer_to_question": "Adicionado filtro para respostas onde a resposta à pergunta {questionIdx} é {filterComboBoxValue} - {filterValue} ",
@@ -1715,6 +1799,22 @@
"all_responses_excel": "Todas as respostas (Excel)",
"all_time": "Todo o tempo",
"almost_there": "Quase lá! Instale o widget para começar a receber respostas.",
"anonymous_links": "Links anônimos",
"anonymous_links.custom_start_point": "Ponto de início personalizado",
"anonymous_links.data_prefilling": "preenchimento automático de dados",
"anonymous_links.docs_title": "Faça mais com pesquisas de links",
"anonymous_links.multi_use_link": "Link de uso múltiplo",
"anonymous_links.multi_use_link_alert_description": "Se você desativar, esses outros canais de distribuição também serão desativados",
"anonymous_links.multi_use_link_alert_title": "Este link permite a incorporação em sites, incorporações em e-mails, compartilhamento em redes sociais e códigos QR",
"anonymous_links.multi_use_link_description": "Coletar múltiplas respostas de respondentes anônimos com um link",
"anonymous_links.single_use_link": "Link de uso único",
"anonymous_links.single_use_link_description": "Permitir apenas uma resposta por link da pesquisa.",
"anonymous_links.single_use_link_encryption": "Criptografia de URL de ID de uso único",
"anonymous_links.single_use_link_encryption_alert_description": "Se você não criptografar IDs de uso único, qualquer valor para “suid=...” funciona para uma resposta",
"anonymous_links.single_use_link_encryption_description": "Desative apenas se precisar definir um ID de uso único personalizado",
"anonymous_links.single_use_link_encryption_generate_and_download_links": "Gerar & baixar links",
"anonymous_links.single_use_link_encryption_number_of_links": "Número de links (1 - 5.000)",
"anonymous_links.source_tracking": "rastreamento de origem",
"average": "média",
"completed": "Concluído",
"completed_tooltip": "Número de vezes que a pesquisa foi completada.",

View File

@@ -326,7 +326,6 @@
"response": "Resposta",
"responses": "Respostas",
"restart": "Reiniciar",
"retry": "Repetir",
"role": "Função",
"role_organization": "Função (Organização)",
"saas": "SaaS",
@@ -1250,6 +1249,8 @@
"add_description": "Adicionar descrição",
"add_ending": "Adicionar encerramento",
"add_ending_below": "Adicionar encerramento abaixo",
"add_fallback": "Adicionar",
"add_fallback_placeholder": "Adicionar um espaço reservado para mostrar se a pergunta for ignorada:",
"add_hidden_field_id": "Adicionar ID do campo oculto",
"add_highlight_border": "Adicionar borda de destaque",
"add_highlight_border_description": "Adicione uma borda externa ao seu cartão de inquérito.",
@@ -1388,6 +1389,7 @@
"error_saving_changes": "Erro ao guardar alterações",
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de terem enviado uma resposta (por exemplo, Caixa de Feedback)",
"everyone": "Todos",
"fallback_for": "Alternativa para ",
"fallback_missing": "Substituição em falta",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
"field_name_eg_score_price": "Nome do campo, por exemplo, pontuação, preço",
@@ -1699,14 +1701,96 @@
"results_unpublished_successfully": "Resultados despublicados com sucesso.",
"search_by_survey_name": "Pesquisar por nome do inquérito",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Se não encriptar os IDs de uso único, qualquer valor para “suid=...” funciona para uma resposta",
"custom_single_use_id_title": "Pode definir qualquer valor como ID de uso único no URL.",
"custom_start_point": "Ponto de início personalizado",
"data_prefilling": "Pré-preenchimento de dados",
"description": "Respostas provenientes destes links serão anónimas",
"disable_multi_use_link_modal_button": "Desativar link de uso múltiplo",
"disable_multi_use_link_modal_description": "Desativar o link de uso múltiplo impedirá que alguém submeta uma resposta através do link.",
"disable_multi_use_link_modal_description_one": "Desativar o link de uso múltiplo impedirá que alguém submeta uma resposta através do link.",
"disable_multi_use_link_modal_description_subtext": "Isto também irá quebrar quaisquer incorporações ativas em websites, emails, redes sociais e códigos QR que utilizem este link de uso múltiplo.",
"disable_multi_use_link_modal_description_two": "Isto também irá afetar quaisquer incorporações ativas em websites, emails, redes sociais e códigos QR que utilizem\neste link de uso múltiplo.",
"disable_multi_use_link_modal_title": "Tem a certeza? Isto pode afetar integrações ativas",
"disable_single_use_link_modal_button": "Desativar links de uso único",
"disable_single_use_link_modal_description": "Se partilhou links de uso único, os participantes já não poderão responder ao inquérito.",
"generate_and_download_links": "Gerar & descarregar links",
"generate_links_error": "Não foi possível gerar links de uso único. Por favor, trabalhe diretamente com a API",
"multi_use_link": "Link de uso múltiplo",
"multi_use_link_description": "Recolha múltiplas respostas de respondentes anónimos com um só link.",
"multi_use_powers_other_channels_description": "Se desativar, estes outros canais de distribuição também serão desativados.",
"multi_use_powers_other_channels_title": "Este link alimenta incorporações em Websites, incorporações em Email, partilha em Redes Sociais e Códigos QR.",
"multi_use_toggle_error": "Erro ao ativar links de uso múltiplo, por favor tente novamente mais tarde",
"nav_title": "Links anónimos",
"number_of_links_empty": "Número de links é obrigatório",
"number_of_links_label": "Número de links (1 - 5.000)",
"single_use_link": "Links de uso único",
"single_use_link_description": "Permitir apenas uma resposta por link de inquérito.",
"single_use_links": "Links de uso único",
"source_tracking": "Rastreamento de origem",
"title": "Partilhe o seu inquérito para recolher respostas",
"url_encryption_description": "Desative apenas se precisar definir um ID de uso único personalizado.",
"url_encryption_label": "Encriptação do URL de ID de uso único"
},
"dynamic_popup": {
"alert_button": "Editar inquérito",
"alert_description": "Este questionário está atualmente configurado como um questionário de link, que não suporta pop-ups dinâmicos. Você pode alterar isso na aba de configurações do editor de questionários.",
"alert_title": "Mudar tipo de inquérito para in-app",
"attribute_based_targeting": "Segmentação baseada em atributos",
"code_no_code_triggers": "Gatilhos com código e sem código",
"description": "Os inquéritos Formbricks podem ser incorporados como uma janela pop-up, com base na interação do utilizador.",
"docs_title": "Faça mais com sondagens de interceptação",
"nav_title": "Dinâmico (Pop-up)",
"recontact_options": "Opções de Recontacto",
"title": "Intercepte utilizadores no seu fluxo para recolher feedback contextualizado"
},
"embed_on_website": {
"description": "Os inquéritos Formbricks podem ser incorporados como um elemento estático.",
"embed_code_copied_to_clipboard": "Código incorporado copiado para a área de transferência!",
"embed_in_an_email": "Incorporar num email",
"embed_in_app": "Incorporar na aplicação",
"embed_mode": "Modo de Incorporação",
"embed_mode_description": "Incorpore o seu inquérito com um design minimalista, descartando o preenchimento e o fundo.",
"nav_title": "Incorporar no site",
"title": "Incorporar o questionário na sua página web"
}
},
"personal_links": {
"create_and_manage_segments": "Crie e gere os seus Segmentos em Contactos > Segmentos",
"create_single_use_links": "Criar links de uso único",
"create_single_use_links_description": "Aceitar apenas uma submissão por link. Aqui está como.",
"description": "Gerar links pessoais para um segmento e associar as respostas do inquérito a cada contacto.",
"expiry_date_description": "Uma vez que o link expira, o destinatário não pode mais responder ao questionário.",
"expiry_date_optional": "Data de expiração (opcional)",
"generate_and_download_links": "Gerar & descarregar links",
"generating_links": "Gerando links",
"generating_links_toast": "A gerar links, o download começará em breve…",
"links_generated_success_toast": "Links gerados com sucesso, o seu download começará em breve.",
"nav_title": "Links pessoais",
"no_segments_available": "Sem segmentos disponíveis",
"select_segment": "Selecionar segmento",
"title": "Maximize os insights com links pessoais de inquérito",
"upgrade_prompt_description": "Gerar links pessoais para um segmento e associar as respostas do inquérito a cada contacto.",
"upgrade_prompt_title": "Utilize links pessoais com um plano superior",
"work_with_segments": "Os links pessoais funcionam com segmentos."
},
"send_email": {
"copy_embed_code": "Copiar código de incorporação",
"description": "Incorpora o teu inquérito num email para obter respostas do teu público.",
"email_preview_tab": "Pré-visualização de Email",
"email_sent": "Email enviado!",
"email_subject_label": "Assunto",
"email_to_label": "Para",
"embed_code_copied_to_clipboard": "Código incorporado copiado para a área de transferência!",
"embed_code_copied_to_clipboard_failed": "A cópia falhou, por favor, tente novamente",
"embed_code_tab": "Código de Incorporação",
"formbricks_email_survey_preview": "Pré-visualização da Pesquisa de E-mail do Formbricks",
"nav_title": "Incorporação de Email",
"send_preview": "Enviar pré-visualização",
"send_preview_email": "Enviar pré-visualização de email",
"title": "Incorporar o seu inquérito num email"
},
"share_view_title": "Partilhar via"
},
"summary": {
"added_filter_for_responses_where_answer_to_question": "Adicionado filtro para respostas onde a resposta à pergunta {questionIdx} é {filterComboBoxValue} - {filterValue} ",
@@ -1715,6 +1799,22 @@
"all_responses_excel": "Todas as respostas (Excel)",
"all_time": "Todo o tempo",
"almost_there": "Quase lá! Instale o widget para começar a receber respostas.",
"anonymous_links": "Links anónimos",
"anonymous_links.custom_start_point": "Ponto de início personalizado",
"anonymous_links.data_prefilling": "Pré-preenchimento de dados",
"anonymous_links.docs_title": "Faça mais com inquéritos de ligação",
"anonymous_links.multi_use_link": "Link de uso múltiplo",
"anonymous_links.multi_use_link_alert_description": "Se desativar, estes outros canais de distribuição também serão desativados",
"anonymous_links.multi_use_link_alert_title": "Este link alimenta incorporações em Websites, incorporações em Email, partilha em Redes Sociais e Códigos QR",
"anonymous_links.multi_use_link_description": "Recolha múltiplas respostas de respondentes anónimos com um só link",
"anonymous_links.single_use_link": "Link de uso único",
"anonymous_links.single_use_link_description": "Permitir apenas uma resposta por link de inquérito",
"anonymous_links.single_use_link_encryption": "Encriptação do URL de ID de uso único",
"anonymous_links.single_use_link_encryption_alert_description": "Se não encriptar os IDs de uso único, qualquer valor para 'suid=...' funciona para uma resposta",
"anonymous_links.single_use_link_encryption_description": "Desativar apenas se precisar definir um ID de uso único personalizado",
"anonymous_links.single_use_link_encryption_generate_and_download_links": "Gerar & descarregar links",
"anonymous_links.single_use_link_encryption_number_of_links": "Número de links (1 - 5.000)",
"anonymous_links.source_tracking": "Rastreamento de origem",
"average": "Média",
"completed": "Concluído",
"completed_tooltip": "Número de vezes que o inquérito foi concluído.",

View File

@@ -326,7 +326,6 @@
"response": "回應",
"responses": "回應",
"restart": "重新開始",
"retry": "重 試",
"role": "角色",
"role_organization": "角色(組織)",
"saas": "SaaS",
@@ -1250,6 +1249,8 @@
"add_description": "新增描述",
"add_ending": "新增結尾",
"add_ending_below": "在下方新增結尾",
"add_fallback": "新增",
"add_fallback_placeholder": "新增用于顯示問題被跳過時的佔位符",
"add_hidden_field_id": "新增隱藏欄位 ID",
"add_highlight_border": "新增醒目提示邊框",
"add_highlight_border_description": "在您的問卷卡片新增外邊框。",
@@ -1388,6 +1389,7 @@
"error_saving_changes": "儲存變更時發生錯誤",
"even_after_they_submitted_a_response_e_g_feedback_box": "即使他們提交回應之後(例如,意見反應方塊)",
"everyone": "所有人",
"fallback_for": "備用 用於 ",
"fallback_missing": "遺失的回退",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
"field_name_eg_score_price": "欄位名稱,例如:分數、價格",
@@ -1699,14 +1701,96 @@
"results_unpublished_successfully": "結果已成功取消發布。",
"search_by_survey_name": "依問卷名稱搜尋",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "如果您不加密 使用一次 的 ID任何“ suid=...”的值都能用于 一次回應",
"custom_single_use_id_title": "您可以在 URL 中設置任何值 作為 一次性使用 ID",
"custom_start_point": "自訂 開始 點",
"data_prefilling": "資料預先填寫",
"description": "從 這些 連結 獲得 的 回應 將是 匿名 的",
"disable_multi_use_link_modal_button": "禁用 多 重 使用 連結",
"disable_multi_use_link_modal_description": "停用多次使用連結將阻止任何人通過該連結提交回應。",
"disable_multi_use_link_modal_description_one": "停用多次使用連結將阻止任何人通過該連結提交回應。",
"disable_multi_use_link_modal_description_subtext": "這也會破壞在 網頁 、 電子郵件 、社交媒體 和 QR碼上使用此多次使用連結的任何 活動 嵌入 。",
"disable_multi_use_link_modal_description_two": "這也會破壞在網頁、電子郵件、社交媒體和 QR碼上使用此多次使用連結的任何 活動 嵌入。",
"disable_multi_use_link_modal_title": "您確定嗎?這可能會破壞 活動 嵌入 ",
"disable_single_use_link_modal_button": "停用 單次使用連結",
"disable_single_use_link_modal_description": "如果您共享了單次使用連結,參與者將不再能夠回應此問卷。",
"generate_and_download_links": "生成 & 下載 連結",
"generate_links_error": "無法生成單次使用連結。請直接使用 API",
"multi_use_link": "多 重 使用 連結",
"multi_use_link_description": "收集 多位 匿名 受訪者 的 多次 回應 使用 一個 連結",
"multi_use_powers_other_channels_description": "如果您停用它,這些其他分發管道也會被停用",
"multi_use_powers_other_channels_title": "這個 連結 支援 網站 嵌入 、 電子郵件 嵌入 、 社交 媒體 分享 和 QR 碼",
"multi_use_toggle_error": "啟用多 重 使用 連結時出 錯 請稍候再試",
"nav_title": "匿名 連結",
"number_of_links_empty": "需要輸入連結數量",
"number_of_links_label": "連結數量 (1 - 5,000)",
"single_use_link": "單次使用連結",
"single_use_link_description": "只允許 1 個回應每個問卷連結。",
"single_use_links": "單次使用連結",
"source_tracking": "來源追蹤",
"title": "分享 您 的 調查來 收集 回應",
"url_encryption_description": "僅在需要設定自訂一次性 ID 時停用",
"url_encryption_label": "單次使用 ID 的 URL 加密"
},
"dynamic_popup": {
"alert_button": "編輯 問卷",
"alert_description": "此 問卷 目前 被 設定 為 連結 問卷,不 支援 動態 彈出窗口。您 可 在 問卷 編輯器 的 設定 標籤 中 進行 更改。",
"alert_title": "更改問卷類型為 in-app",
"attribute_based_targeting": "屬性 基於 的 定位",
"code_no_code_triggers": "程式碼 及 無程式碼 觸發器",
"description": "Formbricks 調查 可以 嵌入 為 彈出 式 樣 式 根據 使用者 互動 。",
"docs_title": "使用 截圖 調查 來 完成 更多 工作",
"nav_title": "動態(彈窗)",
"recontact_options": "重新聯絡選項",
"title": "攔截使用者於其流程中以收集具上下文的意見反饋"
},
"embed_on_website": {
"description": "Formbricks 調查可以 作為 靜態 元素 嵌入。",
"embed_code_copied_to_clipboard": "嵌入程式碼已複製到剪貼簿!",
"embed_in_an_email": "嵌入電子郵件中",
"embed_in_app": "嵌入應用程式",
"embed_mode": "嵌入模式",
"embed_mode_description": "以簡約設計嵌入您的問卷,捨棄邊距和背景。",
"nav_title": "嵌入網站",
"title": "嵌入 調查 在 您 的 網頁"
}
},
"personal_links": {
"create_and_manage_segments": "在 聯絡人 > 分段 中建立和管理您的分段",
"create_single_use_links": "建立單次使用連結",
"create_single_use_links_description": "每個連結只接受一次提交。以下是如何操作。",
"description": "為 一個 群組 生成 個人 連結,並 將 調查 回應 對應 到 每個 聯絡人。",
"expiry_date_description": "一旦連結過期,收件者將無法再回應 survey。",
"expiry_date_optional": "到期日 (可選)",
"generate_and_download_links": "生成 & 下載 連結",
"generating_links": "生成 連結",
"generating_links_toast": "生成 連結,下載 將 會 很快 開始…",
"links_generated_success_toast": "連結 成功 生成,您的 下載 將 會 很快 開始。",
"nav_title": "個人 連結",
"no_segments_available": "沒有可用的區段",
"select_segment": "選擇 區隔",
"title": "透過個人化調查連結最大化洞察",
"upgrade_prompt_description": "為一個群組生成個人連結,並將調查回應連結到每個聯絡人。",
"upgrade_prompt_title": "使用 個人 連結 與 更高 的 計劃",
"work_with_segments": "個人 連結 可 與 分段 一起 使用"
},
"send_email": {
"copy_embed_code": "複製嵌入程式碼",
"description": "將 你的 調查 嵌入 在 電子郵件 中 以 獲得 觀眾 的 回應。",
"email_preview_tab": "電子郵件預覽",
"email_sent": "已發送電子郵件!",
"email_subject_label": "主旨",
"email_to_label": "收件者",
"embed_code_copied_to_clipboard": "嵌入程式碼已複製到剪貼簿!",
"embed_code_copied_to_clipboard_failed": "複製失敗,請再試一次",
"embed_code_tab": "嵌入程式碼",
"formbricks_email_survey_preview": "Formbricks 電子郵件問卷預覽",
"nav_title": "電子郵件嵌入",
"send_preview": "發送預覽",
"send_preview_email": "發送預覽電子郵件",
"title": "嵌入 你的 調查 在 電子郵件 中"
},
"share_view_title": "透過 分享"
},
"summary": {
"added_filter_for_responses_where_answer_to_question": "已新增回應的篩選器,其中問題 '{'questionIdx'}' 的答案為 '{'filterComboBoxValue'}' - '{'filterValue'}'",
@@ -1715,6 +1799,22 @@
"all_responses_excel": "所有回應 (Excel)",
"all_time": "全部時間",
"almost_there": "快完成了!安裝小工具以開始接收回應。",
"anonymous_links": "匿名 連結",
"anonymous_links.custom_start_point": "自訂 開始 點",
"anonymous_links.data_prefilling": "資料預先填寫",
"anonymous_links.docs_title": "使用 連結 問卷 來 完成 更多 事情",
"anonymous_links.multi_use_link": "多 重 使用 連結",
"anonymous_links.multi_use_link_alert_description": "如果您停用它,這些其他分發管道也會被停用",
"anonymous_links.multi_use_link_alert_title": "這個 連結 支援 網站 嵌入 、 電子郵件 嵌入 、 社交 媒體 分享 和 QR 碼",
"anonymous_links.multi_use_link_description": "收集 多位 匿名 受訪者 的 多次 回應 使用 一個 連結",
"anonymous_links.single_use_link": "單次使用連結",
"anonymous_links.single_use_link_description": "只允許 1 個回應每個問卷連結。",
"anonymous_links.single_use_link_encryption": "單次使用 ID 的 URL 加密",
"anonymous_links.single_use_link_encryption_alert_description": "如果您不加密 使用一次 的 ID任何“ suid=...”的值都能用于 一次回應",
"anonymous_links.single_use_link_encryption_description": "僅在需要設定自訂一次性 ID 時停用",
"anonymous_links.single_use_link_encryption_generate_and_download_links": "生成 & 下載 連結",
"anonymous_links.single_use_link_encryption_number_of_links": "連結數量 (1 - 5,000)",
"anonymous_links.source_tracking": "來源追蹤",
"average": "平均",
"completed": "已完成",
"completed_tooltip": "問卷已完成的次數。",

View File

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

View File

@@ -11,7 +11,7 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
<Input
data-testid="survey-url-input"
autoFocus={true}
className="h-9 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-3 py-1 text-slate-800 caret-transparent"
className="h-9 w-full text-ellipsis rounded-lg border bg-white px-3 py-1 text-slate-800 caret-transparent"
value={surveyUrl}
readOnly
/>

View File

@@ -1,209 +1,246 @@
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
import { generateSingleUseIdAction } from "@/modules/survey/list/actions";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink/index";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { toast } from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ShareSurveyLink } from "./index";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
const dummySurvey = {
id: "survey123",
singleUse: { enabled: true, isEncrypted: false },
vi.mock("react-hot-toast", () => ({
toast: {
success: vi.fn(),
},
}));
const survey: TSurvey = {
id: "survey-id",
name: "Test Survey",
type: "link",
status: "completed",
} as any;
const dummyPublicDomain = "http://dummy.com";
const dummyLocale = "en-US";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
}));
vi.mock("@/modules/survey/list/actions", () => ({
generateSingleUseIdAction: vi.fn(),
}));
vi.mock("@/modules/survey/lib/client-utils", () => ({
copySurveyLink: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn((error: any) => error.message),
}));
vi.mock("./components/LanguageDropdown", () => {
const React = require("react");
return {
LanguageDropdown: (props: { setLanguage: (lang: string) => void }) => {
// Call setLanguage("fr-FR") when the component mounts to simulate a language change.
React.useEffect(() => {
props.setLanguage("fr-FR");
}, [props.setLanguage]);
return <div>Mocked LanguageDropdown</div>;
status: "inProgress",
questions: [
{
id: "question-id",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question headline" },
subheader: { default: "Question subheader" },
required: true,
buttonLabel: { default: "Next" },
inputType: "text",
charLimit: { enabled: false },
},
};
],
recontactDays: 1,
autoClose: null,
closeOnDate: null,
delay: 0,
displayPercentage: null,
displayLimit: null,
triggers: [],
redirectUrl: null,
numDisplays: 0,
numDisplaysGlobally: 0,
numResponses: 0,
numResponsesGlobally: 0,
createdAt: new Date(),
updatedAt: new Date(),
languages: [
{
default: true,
enabled: true,
language: {
id: "lang-1",
code: "en",
alias: "English",
createdAt: new Date(),
updatedAt: new Date(),
projectId: "proj-1",
},
},
{
default: false,
enabled: true,
language: {
id: "lang-2",
code: "de",
alias: "German",
createdAt: new Date(),
updatedAt: new Date(),
projectId: "proj-1",
},
},
],
styling: null,
variables: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome!" },
timeToFinish: false,
showResponseCount: false,
},
surveyClosedMessage: null,
singleUse: null,
productOverwrites: null,
resultShareKey: null,
pin: null,
verifyEmail: null,
attributeFilters: [],
autoComplete: null,
hiddenFields: { enabled: true },
environmentId: "env-id",
endings: [],
displayOption: "displayOnce",
isBackButtonHidden: false,
isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false,
recaptcha: { enabled: false, threshold: 0.5 },
segment: null,
showLanguageSwitch: false,
createdBy: "user-id",
followUps: [],
} as unknown as TSurvey;
const publicDomain = "http://localhost:3000";
let surveyUrl = `${publicDomain}/s/survey-id`;
const setSurveyUrl = vi.fn((url: string) => {
surveyUrl = url;
});
const locale: TUserLocale = "en-US";
// Mocking dependencies
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
Object.assign(navigator, {
clipboard: {
writeText: vi.fn(),
},
});
global.open = vi.fn();
describe("ShareSurveyLink", () => {
beforeEach(() => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
window.open = vi.fn();
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
surveyUrl = `${publicDomain}/s/survey-id`;
});
test("calls getUrl on mount and sets surveyUrl accordingly with singleUse enabled and default language", async () => {
// Inline mocks for this test
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: "dummySuId" });
const setSurveyUrl = vi.fn();
test("renders the component with initial values", () => {
render(
<ShareSurveyLink
survey={dummySurvey}
publicDomain={dummyPublicDomain}
surveyUrl=""
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
/>
);
await waitFor(() => {
expect(setSurveyUrl).toHaveBeenCalled();
});
const url = setSurveyUrl.mock.calls[0][0];
expect(url).toContain(`${dummyPublicDomain}/s/${dummySurvey.id}?suId=dummySuId`);
expect(url).not.toContain("lang=");
});
test("appends language query when language is changed from default", async () => {
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: "dummySuId" });
const setSurveyUrl = vi.fn();
const DummyWrapper = () => (
<ShareSurveyLink
survey={dummySurvey}
publicDomain={dummyPublicDomain}
surveyUrl="initial"
setSurveyUrl={setSurveyUrl}
locale="fr-FR"
/>
);
render(<DummyWrapper />);
await waitFor(() => {
const generatedUrl = setSurveyUrl.mock.calls[1][0];
expect(generatedUrl).toContain("lang=fr-FR");
});
});
test("preview button opens new window with preview query", async () => {
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: "dummySuId" });
const setSurveyUrl = vi.fn().mockReturnValue(`${dummyPublicDomain}/s/${dummySurvey.id}?suId=dummySuId`);
render(
<ShareSurveyLink
survey={dummySurvey}
publicDomain={dummyPublicDomain}
surveyUrl={`${dummyPublicDomain}/s/${dummySurvey.id}?suId=dummySuId`}
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
/>
);
const previewButton = await screen.findByRole("button", {
name: /environments.surveys.preview_survey_in_a_new_tab/i,
});
fireEvent.click(previewButton);
await waitFor(() => {
expect(window.open).toHaveBeenCalled();
const previewUrl = vi.mocked(window.open).mock.calls[0][0];
expect(previewUrl).toMatch(/\?suId=dummySuId(&|\\?)preview=true/);
});
});
test("copy button writes surveyUrl to clipboard and shows toast", async () => {
vi.mocked(getFormattedErrorMessage).mockReturnValue("common.copied_to_clipboard");
vi.mocked(copySurveyLink).mockImplementation((url: string, newId: string) => `${url}?suId=${newId}`);
const setSurveyUrl = vi.fn();
const surveyUrl = `${dummyPublicDomain}/s/${dummySurvey.id}?suId=dummySuId`;
render(
<ShareSurveyLink
survey={dummySurvey}
publicDomain={dummyPublicDomain}
survey={survey}
publicDomain={publicDomain}
surveyUrl={surveyUrl}
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
locale={locale}
/>
);
const copyButton = await screen.findByRole("button", {
name: /environments.surveys.copy_survey_link_to_clipboard/i,
});
fireEvent.click(copyButton);
await waitFor(() => {
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(surveyUrl);
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
});
expect(screen.getByDisplayValue(surveyUrl)).toBeInTheDocument();
expect(screen.getByText("common.copy")).toBeInTheDocument();
expect(screen.getByText("common.preview")).toBeInTheDocument();
});
test("renders regenerate button when survey.singleUse.enabled is true and calls generateNewSingleUseLink", async () => {
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: "dummySuId" });
const setSurveyUrl = vi.fn();
test("copies the survey link to the clipboard when copy button is clicked", () => {
render(
<ShareSurveyLink
survey={dummySurvey}
publicDomain={dummyPublicDomain}
surveyUrl={`${dummyPublicDomain}/s/${dummySurvey.id}?suId=dummySuId`}
survey={survey}
publicDomain={publicDomain}
surveyUrl={surveyUrl}
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
locale={locale}
/>
);
const regenButton = await screen.findByRole("button", { name: /Regenerate single use survey link/i });
fireEvent.click(regenButton);
await waitFor(() => {
expect(generateSingleUseIdAction).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith("environments.surveys.new_single_use_link_generated");
});
const copyButton = screen.getByLabelText("environments.surveys.copy_survey_link_to_clipboard");
fireEvent.click(copyButton);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(surveyUrl);
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
});
test("handles error when generating single-use link fails", async () => {
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: undefined });
vi.mocked(getFormattedErrorMessage).mockReturnValue("Failed to generate link");
const setSurveyUrl = vi.fn();
test("opens the preview link in a new tab when preview button is clicked (no query params)", () => {
render(
<ShareSurveyLink
survey={dummySurvey}
publicDomain={dummyPublicDomain}
survey={survey}
publicDomain={publicDomain}
surveyUrl={surveyUrl}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
);
const previewButton = screen.getByLabelText("environments.surveys.preview_survey_in_a_new_tab");
fireEvent.click(previewButton);
expect(global.open).toHaveBeenCalledWith(`${surveyUrl}?preview=true`, "_blank");
});
test("opens the preview link in a new tab when preview button is clicked (with query params)", () => {
const surveyWithParamsUrl = `${publicDomain}/s/survey-id?foo=bar`;
render(
<ShareSurveyLink
survey={survey}
publicDomain={publicDomain}
surveyUrl={surveyWithParamsUrl}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
);
const previewButton = screen.getByLabelText("environments.surveys.preview_survey_in_a_new_tab");
fireEvent.click(previewButton);
expect(global.open).toHaveBeenCalledWith(`${surveyWithParamsUrl}&preview=true`, "_blank");
});
test("disables copy and preview buttons when surveyUrl is empty", () => {
render(
<ShareSurveyLink
survey={survey}
publicDomain={publicDomain}
surveyUrl=""
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
locale={locale}
/>
);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Failed to generate link");
});
const copyButton = screen.getByLabelText("environments.surveys.copy_survey_link_to_clipboard");
const previewButton = screen.getByLabelText("environments.surveys.preview_survey_in_a_new_tab");
expect(copyButton).toBeDisabled();
expect(previewButton).toBeDisabled();
});
test("updates the survey URL when the language is changed", () => {
const { rerender } = render(
<ShareSurveyLink
survey={survey}
publicDomain={publicDomain}
surveyUrl={surveyUrl}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
);
const languageDropdown = screen.getByTitle("Select Language");
fireEvent.click(languageDropdown);
const germanOption = screen.getByText("German");
fireEvent.click(germanOption);
rerender(
<ShareSurveyLink
survey={survey}
publicDomain={publicDomain}
surveyUrl={surveyUrl}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
);
expect(setSurveyUrl).toHaveBeenCalled();
expect(surveyUrl).toContain("lang=de");
});
});

View File

@@ -1,10 +1,8 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { Copy, RefreshCcw, SquareArrowOutUpRight } from "lucide-react";
import { useEffect, useState } from "react";
import { Copy, SquareArrowOutUpRight } from "lucide-react";
import { toast } from "react-hot-toast";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
@@ -28,38 +26,29 @@ export const ShareSurveyLink = ({
locale,
}: ShareSurveyLinkProps) => {
const { t } = useTranslate();
const [language, setLanguage] = useState("default");
useEffect(() => {
const fetchSurveyUrl = async () => {
try {
const url = await getSurveyUrl(survey, publicDomain, language);
setSurveyUrl(url);
} catch (error) {
const errorMessage = getFormattedErrorMessage(error);
toast.error(errorMessage);
}
};
fetchSurveyUrl();
}, [survey, language, publicDomain, setSurveyUrl]);
const generateNewSingleUseLink = async () => {
try {
const newUrl = await getSurveyUrl(survey, publicDomain, language);
setSurveyUrl(newUrl);
toast.success(t("environments.surveys.new_single_use_link_generated"));
} catch (error) {
const errorMessage = getFormattedErrorMessage(error);
toast.error(errorMessage);
}
const handleLanguageChange = (language: string) => {
const url = getSurveyUrl(survey, publicDomain, language);
setSurveyUrl(url);
};
return (
<div
className={`flex max-w-full flex-col items-center justify-center gap-2 ${survey.singleUse?.enabled ? "flex-col" : "lg:flex-row"}`}>
<div className={"flex max-w-full flex-col items-center justify-center gap-2 md:flex-row"}>
<SurveyLinkDisplay surveyUrl={surveyUrl} key={surveyUrl} />
<div className="flex items-center justify-center space-x-2">
<LanguageDropdown survey={survey} setLanguage={setLanguage} locale={locale} />
<LanguageDropdown survey={survey} setLanguage={handleLanguageChange} locale={locale} />
<Button
disabled={!surveyUrl}
variant="secondary"
title={t("environments.surveys.copy_survey_link_to_clipboard")}
aria-label={t("environments.surveys.copy_survey_link_to_clipboard")}
onClick={() => {
navigator.clipboard.writeText(surveyUrl);
toast.success(t("common.copied_to_clipboard"));
}}>
{t("common.copy")}
<Copy />
</Button>
<Button
title={t("environments.surveys.preview_survey_in_a_new_tab")}
aria-label={t("environments.surveys.preview_survey_in_a_new_tab")}
@@ -76,27 +65,6 @@ export const ShareSurveyLink = ({
{t("common.preview")}
<SquareArrowOutUpRight />
</Button>
<Button
disabled={!surveyUrl}
variant="secondary"
title={t("environments.surveys.copy_survey_link_to_clipboard")}
aria-label={t("environments.surveys.copy_survey_link_to_clipboard")}
onClick={() => {
navigator.clipboard.writeText(surveyUrl);
toast.success(t("common.copied_to_clipboard"));
}}>
{t("common.copy")}
<Copy />
</Button>
{survey.singleUse?.enabled && (
<Button
disabled={!surveyUrl}
title="Regenerate single use survey link"
aria-label="Regenerate single use survey link"
onClick={generateNewSingleUseLink}>
<RefreshCcw className="h-5 w-5" />
</Button>
)}
</div>
</div>
);

View File

@@ -1,5 +1,3 @@
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { generateSingleUseIdAction } from "@/modules/survey/list/actions";
import { JSX } from "react";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -30,28 +28,10 @@ export const renderHyperlinkedContent = (data: string): JSX.Element[] => {
);
};
export const getSurveyUrl = async (
survey: TSurvey,
publicDomain: string,
language: string
): Promise<string> => {
export const getSurveyUrl = (survey: TSurvey, publicDomain: string, language: string): string => {
let url = `${publicDomain}/s/${survey.id}`;
const queryParams: string[] = [];
if (survey.singleUse?.enabled) {
const singleUseIdResponse = await generateSingleUseIdAction({
surveyId: survey.id,
isEncrypted: survey.singleUse.isEncrypted,
});
if (singleUseIdResponse?.data) {
queryParams.push(`suId=${singleUseIdResponse.data}`);
} else {
const errorMessage = getFormattedErrorMessage(singleUseIdResponse);
throw new Error(errorMessage);
}
}
if (language !== "default") {
queryParams.push(`lang=${language}`);
}

View File

@@ -1,5 +1,5 @@
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { generateSingleUseIdAction } from "@/modules/survey/list/actions";
import { generateSingleUseIdsAction } from "@/modules/survey/list/actions";
import { act, renderHook, waitFor } from "@testing-library/react";
import toast from "react-hot-toast";
import { describe, expect, test, vi } from "vitest";
@@ -8,7 +8,7 @@ import { useSingleUseId } from "./useSingleUseId";
// Mock external functions
vi.mock("@/modules/survey/list/actions", () => ({
generateSingleUseIdAction: vi.fn().mockResolvedValue({ data: "initialId" }),
generateSingleUseIdsAction: vi.fn().mockResolvedValue({ data: ["initialId"] }),
}));
vi.mock("@/lib/utils/helper", () => ({
@@ -32,7 +32,7 @@ describe("useSingleUseId", () => {
} as TSurvey;
test("should initialize singleUseId to undefined", () => {
vi.mocked(generateSingleUseIdAction).mockResolvedValueOnce({ data: "mockSingleUseId" });
vi.mocked(generateSingleUseIdsAction).mockResolvedValueOnce({ data: ["mockSingleUseId"] });
const { result } = renderHook(() => useSingleUseId(mockSurvey));
@@ -41,7 +41,7 @@ describe("useSingleUseId", () => {
});
test("should fetch and set singleUseId if singleUse is enabled", async () => {
vi.mocked(generateSingleUseIdAction).mockResolvedValueOnce({ data: "mockSingleUseId" });
vi.mocked(generateSingleUseIdsAction).mockResolvedValueOnce({ data: ["mockSingleUseId"] });
const { result, rerender } = renderHook((props) => useSingleUseId(props), {
initialProps: mockSurvey,
@@ -52,9 +52,10 @@ describe("useSingleUseId", () => {
expect(result.current.singleUseId).toBe("mockSingleUseId");
});
expect(generateSingleUseIdAction).toHaveBeenCalledWith({
expect(generateSingleUseIdsAction).toHaveBeenCalledWith({
surveyId: "survey123",
isEncrypted: true,
count: 1,
});
// Re-render with the same props to ensure it doesn't break
@@ -80,11 +81,11 @@ describe("useSingleUseId", () => {
expect(result.current.singleUseId).toBeUndefined();
});
expect(generateSingleUseIdAction).not.toHaveBeenCalled();
expect(generateSingleUseIdsAction).not.toHaveBeenCalled();
});
test("should show toast error if the API call fails", async () => {
vi.mocked(generateSingleUseIdAction).mockResolvedValueOnce({ serverError: "Something went wrong" });
vi.mocked(generateSingleUseIdsAction).mockResolvedValueOnce({ serverError: "Something went wrong" });
const { result } = renderHook(() => useSingleUseId(mockSurvey));
@@ -98,19 +99,19 @@ describe("useSingleUseId", () => {
test("should refreshSingleUseId on demand", async () => {
// Set up the initial mock response
vi.mocked(generateSingleUseIdAction).mockResolvedValueOnce({ data: "initialId" });
vi.mocked(generateSingleUseIdsAction).mockResolvedValueOnce({ data: ["initialId"] });
const { result } = renderHook(() => useSingleUseId(mockSurvey));
// We need to wait for the initial async effect to complete
// This ensures the hook has time to update state with the first mock value
await waitFor(() => {
expect(generateSingleUseIdAction).toHaveBeenCalledTimes(1);
expect(generateSingleUseIdsAction).toHaveBeenCalledTimes(1);
});
// Reset the mock and set up the next response for refreshSingleUseId call
vi.mocked(generateSingleUseIdAction).mockClear();
vi.mocked(generateSingleUseIdAction).mockResolvedValueOnce({ data: "refreshedId" });
vi.mocked(generateSingleUseIdsAction).mockClear();
vi.mocked(generateSingleUseIdsAction).mockResolvedValueOnce({ data: ["refreshedId"] });
// Call refreshSingleUseId and wait for it to complete
let refreshedValue;
@@ -125,9 +126,10 @@ describe("useSingleUseId", () => {
expect(result.current.singleUseId).toBe("refreshedId");
// Verify the API was called with correct parameters
expect(generateSingleUseIdAction).toHaveBeenCalledWith({
expect(generateSingleUseIdsAction).toHaveBeenCalledWith({
surveyId: "survey123",
isEncrypted: true,
count: 1,
});
});
});

View File

@@ -1,7 +1,7 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { generateSingleUseIdAction } from "@/modules/survey/list/actions";
import { generateSingleUseIdsAction } from "@/modules/survey/list/actions";
import { TSurvey as TSurveyList } from "@/modules/survey/list/types/surveys";
import { useCallback, useEffect, useState } from "react";
import toast from "react-hot-toast";
@@ -12,13 +12,15 @@ export const useSingleUseId = (survey: TSurvey | TSurveyList) => {
const refreshSingleUseId = useCallback(async () => {
if (survey.singleUse?.enabled) {
const response = await generateSingleUseIdAction({
const response = await generateSingleUseIdsAction({
surveyId: survey.id,
isEncrypted: !!survey.singleUse?.isEncrypted,
count: 1,
});
if (response?.data) {
setSingleUseId(response.data);
return response.data;
if (!!response?.data?.length) {
setSingleUseId(response.data[0]);
return response.data[0];
} else {
const errorMessage = getFormattedErrorMessage(response);
toast.error(errorMessage);

View File

@@ -9,7 +9,7 @@ import {
getProjectIdFromEnvironmentId,
getProjectIdFromSurveyId,
} from "@/lib/utils/helper";
import { generateSurveySingleUseId } from "@/lib/utils/single-use-surveys";
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getProjectIdIfEnvironmentExists } from "@/modules/survey/list/lib/environment";
import { getUserProjects } from "@/modules/survey/list/lib/project";
@@ -191,9 +191,10 @@ export const deleteSurveyAction = authenticatedActionClient.schema(ZDeleteSurvey
const ZGenerateSingleUseIdAction = z.object({
surveyId: z.string().cuid2(),
isEncrypted: z.boolean(),
count: z.number().min(1).max(5000).default(1),
});
export const generateSingleUseIdAction = authenticatedActionClient
export const generateSingleUseIdsAction = authenticatedActionClient
.schema(ZGenerateSingleUseIdAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
@@ -212,7 +213,7 @@ export const generateSingleUseIdAction = authenticatedActionClient
],
});
return generateSurveySingleUseId(parsedInput.isEncrypted);
return generateSurveySingleUseIds(parsedInput.count, parsedInput.isEncrypted);
});
const ZGetSurveysAction = z.object({

View File

@@ -38,9 +38,10 @@ export const AdvancedOptionToggle = ({
</div>
{children && isChecked && (
<div
className={`mt-4 flex w-full items-center space-x-1 rounded-lg ${
childBorder ? "border" : ""
} bg-slate-50`}>
className={cn(
"mt-4 flex w-full items-center space-x-1 overflow-hidden rounded-lg bg-slate-50",
childBorder && "border"
)}>
{children}
</div>
)}

View File

@@ -2,14 +2,20 @@
import { cn } from "@/lib/cn";
import { VariantProps, cva } from "class-variance-authority";
import { AlertCircle, AlertTriangle, CheckCircle2Icon, Info } from "lucide-react";
import {
AlertCircleIcon,
AlertTriangleIcon,
ArrowUpRightIcon,
CheckCircle2Icon,
InfoIcon,
} from "lucide-react";
import * as React from "react";
import { createContext, useContext } from "react";
import { Button, ButtonProps } from "../button";
// Create a context to share variant and size with child components
interface AlertContextValue {
variant?: "default" | "error" | "warning" | "info" | "success" | null;
variant?: "default" | "error" | "warning" | "info" | "success" | "outbound" | null;
size?: "default" | "small" | null;
}
@@ -21,10 +27,11 @@ const AlertContext = createContext<AlertContextValue>({
const useAlertContext = () => useContext(AlertContext);
// Define alert styles with variants
const alertVariants = cva("relative w-full rounded-lg border [&>svg]:size-4", {
const alertVariants = cva("relative w-full rounded-lg border [&>svg]:size-4 bg-white", {
variants: {
variant: {
default: "text-foreground border-border",
outbound: "text-foreground border-border",
error:
"text-error-foreground [&>svg]:text-error border-error/50 [&_button]:bg-error-background [&_button]:text-error-foreground [&_button:hover]:bg-error-background-muted [&_a]:bg-error-background [&_a]:text-error-foreground [&_a:hover]:bg-error-background-muted",
warning:
@@ -46,11 +53,15 @@ const alertVariants = cva("relative w-full rounded-lg border [&>svg]:size-4", {
},
});
const alertVariantIcons: Record<"default" | "error" | "warning" | "info" | "success", React.ReactNode> = {
const alertVariantIcons: Record<
"default" | "error" | "warning" | "info" | "success" | "outbound",
React.ReactNode
> = {
default: null,
error: <AlertCircle className="size-4" />,
warning: <AlertTriangle className="size-4" />,
info: <Info className="size-4" />,
outbound: <ArrowUpRightIcon className="size-4" />,
error: <AlertCircleIcon className="size-4" />,
warning: <AlertTriangleIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
success: <CheckCircle2Icon className="size-4" />,
};
@@ -140,4 +151,4 @@ const AlertButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
AlertButton.displayName = "AlertButton";
// Export the new component
export { Alert, AlertTitle, AlertDescription, AlertButton };
export { Alert, AlertButton, AlertDescription, AlertTitle };

View File

@@ -32,7 +32,7 @@ export const CodeBlock = ({
}, [children]);
return (
<div className={cn("group relative rounded-md text-sm text-slate-200", noMargin ? "" : "mt-4")}>
<div className={cn("group relative w-full rounded-md text-xs", noMargin ? "" : "mt-4")}>
{showCopyToClipboard && (
<div className="absolute right-2 top-2 z-20 flex cursor-pointer items-center justify-center p-1.5 text-slate-500 hover:text-slate-900">
<CopyIcon
@@ -46,7 +46,7 @@ export const CodeBlock = ({
/>
</div>
)}
<pre className={customEditorClass}>
<pre className={cn("w-full overflow-x-auto rounded-lg", customEditorClass)}>
<code className={cn(`language-${language} whitespace-pre-wrap`, customCodeClass)}>{children}</code>
</pre>
</div>

View File

@@ -69,7 +69,7 @@ export const DatePicker = ({ date, updateSurveyDate, minDate, onClearDate }: Dat
<Button
variant={"ghost"}
className={cn(
"w-[280px] justify-start border border-slate-300 bg-white text-left font-normal transition-all ease-in hover:bg-slate-300",
"w-[280px] justify-start border border-slate-300 bg-white text-left font-normal",
!formattedDate && "text-muted-foreground bg-slate-800"
)}
ref={btnRef}>
@@ -80,7 +80,7 @@ export const DatePicker = ({ date, updateSurveyDate, minDate, onClearDate }: Dat
<Button
variant={"ghost"}
className={cn(
"w-[280px] justify-start border border-slate-300 bg-white text-left font-normal hover:bg-slate-300",
"w-[280px] justify-start border border-slate-300 bg-white text-left font-normal",
!formattedDate && "text-muted-foreground"
)}
onClick={() => setIsOpen(true)}
@@ -124,7 +124,7 @@ export const DatePicker = ({ date, updateSurveyDate, minDate, onClearDate }: Dat
</Popover>
{formattedDate && onClearDate && (
<Button
variant="ghost"
variant="outline"
size="sm"
onClick={handleClearDate}
className="h-8 w-8 p-0 hover:bg-slate-200">

View File

@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
"flex h-9 w-full items-center justify-between rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
className
)}
{...props}>

View File

@@ -53,7 +53,7 @@ export const TabBar: React.FC<TabBarProps> = ({
)}
aria-label="Tabs">
{tabs.map((tab) => (
<div className="flex h-full flex-1 justify-center px-3 py-2" key={tab.id}>
<div className="flex h-full flex-1 justify-center p-1" key={tab.id}>
<button
onClick={() => !disabled && setActiveId(tab.id)}
type="button"
@@ -61,7 +61,7 @@ export const TabBar: React.FC<TabBarProps> = ({
tab.id === activeId
? `bg-white font-semibold text-slate-900 ${activeTabClassName}`
: "text-slate-500",
"h-full w-full items-center rounded-lg text-center text-sm font-medium",
"h-full w-full items-center rounded-md text-center text-sm font-medium",
disabled ? "cursor-not-allowed" : "hover:text-slate-700"
)}
aria-current={tab.id === activeId ? "page" : undefined}>

View File

@@ -39,8 +39,9 @@ describe("Typography Components", () => {
expect(h3Element).toBeInTheDocument();
expect(h3Element).toHaveTextContent("Heading 3");
expect(h3Element?.className).toContain("text-lg");
expect(h3Element?.className).toContain("tracking-tight");
expect(h3Element?.className).toContain("text-slate-800");
expect(h3Element?.className).toContain("font-medium");
expect(h3Element?.className).toContain("scroll-m-20");
});
test("renders H4 correctly", () => {

View File

@@ -41,7 +41,7 @@ const H3 = forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElemen
<h3
{...props}
ref={ref}
className={cn("scroll-m-20 text-lg tracking-tight text-slate-800", props.className)}>
className={cn("scroll-m-20 text-lg font-medium text-slate-800", props.className)}>
{props.children}
</h3>
);