diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts index 0204af4aaa..e2a9ec2391 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts @@ -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; + }); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx index 5a65a21465..115c7e1caf 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx @@ -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 = ({ )} {!isLoading && ( - + )} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx index 2c13f1cdb4..ab49b5b731 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx @@ -147,7 +147,6 @@ export const SurveyAnalysisCTA = ({ - ); -}; - -export const DynamicPopupTab = ({ environmentId, surveyId }: DynamicPopupTabProps) => { - const { t } = useTranslate(); - - return ( -
- - {t("environments.surveys.summary.dynamic_popup.alert_title")} - - {t("environments.surveys.summary.dynamic_popup.alert_description")} - - - - {t("environments.surveys.summary.dynamic_popup.alert_button")} - - - - -
-

{t("environments.surveys.summary.dynamic_popup.title")}

- - - -
-
- ); -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmailTab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmailTab.tsx deleted file mode 100644 index b85e1683af..0000000000 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmailTab.tsx +++ /dev/null @@ -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(""); - const { t } = useTranslate(); - const emailHtml = useMemo(() => { - if (!emailHtmlPreview) return ""; - return emailHtmlPreview - .replaceAll("?preview=true&", "?") - .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 ( -
-
- {showEmbed ? ( - - ) : ( - <> - - - )} - -
- {showEmbed ? ( -
- - {emailHtml} - -
- ) : ( -
-
-
-
-
-
-
-
To : {email || "user@mail.com"}
-
- Subject : {t("environments.surveys.summary.formbricks_email_survey_preview")} -
-
- {emailHtml ? ( -
- ) : ( - - )} -
-
-
- )} -
- ); -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/LinkTab.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/LinkTab.test.tsx deleted file mode 100644 index e16a59ee9d..0000000000 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/LinkTab.test.tsx +++ /dev/null @@ -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 }) => ( -
- Mocked ShareSurveyLink - {survey.id} - {surveyUrl} - {publicDomain} - {locale} -
- )), -})); - -// 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) => ( - - {children} - - ), -})); - -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( - - ); - expect( - screen.getByText("environments.surveys.summary.share_the_link_to_get_responses") - ).toBeInTheDocument(); - }); - - test("renders ShareSurveyLink with correct props", () => { - render( - - ); - 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( - - ); - expect( - screen.getByText("environments.surveys.summary.you_can_do_a_lot_more_with_links_surveys 💡") - ).toBeInTheDocument(); - }); - - test("renders all documentation links correctly", () => { - render( - - ); - - 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" - ); - }); -}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/LinkTab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/LinkTab.tsx deleted file mode 100644 index 371265e99d..0000000000 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/LinkTab.tsx +++ /dev/null @@ -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 ( -
-
-

- {t("environments.surveys.summary.share_the_link_to_get_responses")} -

- -
- -
-

- {t("environments.surveys.summary.you_can_do_a_lot_more_with_links_surveys")} 💡 -

-
- {docsLinks.map((tip) => ( - -

{tip.title}

-

{tip.description}

- - ))} -
-
-
- ); -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab.test.tsx new file mode 100644 index 0000000000..d4ea467b46 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab.test.tsx @@ -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) => ( +
+

Survey URL: {surveyUrl}

+

Public Domain: {publicDomain}

+
+ ), +})); + +vi.mock("@/modules/ui/components/advanced-option-toggle", () => ({ + AdvancedOptionToggle: ({ children, htmlId, isChecked, onToggle, title }: any) => ( +
+ + {children} +
+ ), +})); + +vi.mock("@/modules/ui/components/alert", () => ({ + Alert: ({ children, variant, size }: any) => ( +
+ {children} +
+ ), + AlertTitle: ({ children }: any) =>
{children}
, + AlertDescription: ({ children }: any) =>
{children}
, +})); + +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, onClick, disabled, variant }: any) => ( + + ), +})); + +vi.mock("@/modules/ui/components/input", () => ({ + Input: ({ value, onChange, type, max, min, className }: any) => ( + + ), +})); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container", + () => ({ + TabContainer: ({ children, title }: any) => ( +
+

{title}

+ {children} +
+ ), + }) +); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal", + () => ({ + DisableLinkModal: ({ open, type, onDisable }: any) => ( +
+ + +
+ ), + }) +); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links", + () => ({ + DocumentationLinks: ({ links }: any) => ( +
+ {links.map((link: any, index: number) => ( + + {link.title} + + ))} +
+ ), + }) +); + +// 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(); + + 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(); + + 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(); + + // 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + // 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(); + + 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(); + + 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(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab.tsx new file mode 100644 index 0000000000..135cd41975 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab.tsx @@ -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(1); + + const [disableLinkModal, setDisableLinkModal] = useState<{ + open: boolean; + type: "multi-use" | "single-use"; + pendingAction: () => Promise | 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 => { + 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) => { + 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 ( + +
+ +
+ + +
+ + + {t("environments.surveys.share.anonymous_links.multi_use_powers_other_channels_title")} + + + {t( + "environments.surveys.share.anonymous_links.multi_use_powers_other_channels_description" + )} + + +
+
+
+ + +
+ + + {!singleUseEncryption ? ( + + + {t("environments.surveys.share.anonymous_links.custom_single_use_id_title")} + + + {t("environments.surveys.share.anonymous_links.custom_single_use_id_description")} + + + ) : null} + + {singleUseEncryption && ( +
+

+ {t("environments.surveys.share.anonymous_links.number_of_links_label")} +

+ +
+
+
+ +
+ + +
+
+
+ )} +
+
+
+ + + + {disableLinkModal && ( + setDisableLinkModal(null)} + type={disableLinkModal.type} + onDisable={() => { + disableLinkModal.pendingAction(); + setDisableLinkModal(null); + }} + /> + )} +
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/AppTab.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab.test.tsx similarity index 98% rename from apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/AppTab.test.tsx rename to apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab.test.tsx index 7aebe7cc26..0a733048d1 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/AppTab.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab.test.tsx @@ -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: { diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/AppTab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab.tsx similarity index 100% rename from apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/AppTab.tsx rename to apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab.tsx diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal.test.tsx new file mode 100644 index 0000000000..a05167cba8 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal.test.tsx @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal.tsx new file mode 100644 index 0000000000..c47ef20e84 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal.tsx @@ -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 ( + + + + {type === "multi-use" + ? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title") + : t("common.are_you_sure")} + + + + {type === "multi-use" ? ( + <> +

+ {t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description")} +

+ +
+ +

+ {t( + "environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description_subtext" + )} +

+ + ) : ( +

{t("environments.surveys.share.anonymous_links.disable_single_use_link_modal_description")}

+ )} +
+ + +
+ + + +
+
+
+
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links.test.tsx new file mode 100644 index 0000000000..2ea06df099 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links.test.tsx @@ -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(); + + 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(); + + const alerts = screen.getAllByRole("alert"); + expect(alerts).toHaveLength(3); + }); + + test("renders learn more links with correct href attributes", () => { + render(); + + 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(); + + 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(); + + 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(); + + const alerts = screen.queryAllByRole("alert"); + expect(alerts).toHaveLength(0); + }); + + test("renders single link correctly", () => { + const singleLink = [mockLinks[0]]; + render(); + + 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(); + + 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"); + }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links.tsx new file mode 100644 index 0000000000..55d652c1af --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links.tsx @@ -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 ( +
+ {links.map((link) => ( +
+ + {link.title} + + + {t("common.learn_more")} + + + +
+ ))} +
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/DynamicPopupTab.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab.test.tsx similarity index 65% rename from apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/DynamicPopupTab.test.tsx rename to apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab.test.tsx index 6dd7b10aca..47b0196ec1 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/DynamicPopupTab.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab.test.tsx @@ -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 }) =>
{props.children}
, H4: (props: { children: React.ReactNode }) =>
{props.children}
, + Small: (props: { children: React.ReactNode; color?: string; margin?: string }) => ( +
+ {props.children} +
+ ), })); vi.mock("@tolgee/react", () => ({ @@ -69,18 +75,20 @@ describe("DynamicPopupTab", () => { test("renders alert with correct props", () => { render(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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"); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab.tsx new file mode 100644 index 0000000000..95c8910fc6 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab.tsx @@ -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 ( + +
+ + {t("environments.surveys.share.dynamic_popup.alert_title")} + + {t("environments.surveys.share.dynamic_popup.alert_description")} + + + + {t("environments.surveys.share.dynamic_popup.alert_button")} + + + + + +
+
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmailTab.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab.test.tsx similarity index 64% rename from apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmailTab.test.tsx rename to apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab.test.tsx index 311fa14e66..dbc8b3ceb9 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmailTab.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab.test.tsx @@ -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) => ( - ), })); vi.mock("@/modules/ui/components/code-block", () => ({ - CodeBlock: ({ children, language }: { children: React.ReactNode; language: string }) => ( -
+ CodeBlock: ({ + children, + language, + showCopyToClipboard, + }: { + children: React.ReactNode; + language: string; + showCopyToClipboard?: boolean; + }) => ( +
{children}
), @@ -41,7 +49,9 @@ vi.mock("@/modules/ui/components/loading-spinner", () => ({ vi.mock("lucide-react", () => ({ Code2Icon: () =>
, CopyIcon: () =>
, + EyeIcon: () =>
, MailIcon: () =>
, + SendIcon: () =>
, })); // 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(); 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(); 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(); 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(); 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(); 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 = "

Test1 ?preview=true

Test2 ?preview=true&next

Test3 ?preview=true&;next

"; - // Ensure this line matches the "Received" output from your test error const expectedCleanHtml = "

Test1

Test2 ?next

Test3 ?next

"; 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); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab.tsx new file mode 100644 index 0000000000..bf744c0c8b --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab.tsx @@ -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(""); + const { t } = useTranslate(); + + const emailHtml = useMemo(() => { + if (!emailHtmlPreview) return ""; + return emailHtmlPreview + .replaceAll("?preview=true&", "?") + .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 ( +
+
+
+
+
+
+
+
+
+ {t("environments.surveys.share.send_email.email_to_label")} : {email || "user@mail.com"} +
+
+ {t("environments.surveys.share.send_email.email_subject_label")} :{" "} + {t("environments.surveys.share.send_email.formbricks_email_survey_preview")} +
+
+ {emailHtml ? ( +
+ ) : ( + + )} +
+
+
+ +
+ ); + } + + if (activeTab === "embed") { + return ( +
+ + {emailHtml} + + +
+ ); + } + + return null; + }; + + return ( + +
+ +
{renderTabContent()}
+
+
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab.test.tsx index b03da3e6a2..790bf73ab7 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab.test.tsx @@ -195,12 +195,8 @@ describe("PersonalLinksTab", () => { test("renders the component with correct title and description", () => { render(); - 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(); - 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(); - 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(); - 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(); }); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab.tsx index e7076cc885..c15c7140e8 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab.tsx @@ -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(""); - const [expiryDate, setExpiryDate] = useState(null); + + const form = useForm({ + 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 ( -
-

- {t("environments.surveys.summary.generate_personal_links_title")} -

-

- {t("environments.surveys.summary.generate_personal_links_description")} -

-
+ + +
+ {/* Recipients Section */} + ( + + {t("common.recipients")} + + + + + {t("environments.surveys.share.personal_links.create_and_manage_segments")} + + + )} + /> -
- {/* Recipients Section */} -
- - -

- {t("environments.surveys.summary.create_and_manage_segments")} -

+ {/* Expiry Date Section */} + ( + + {t("environments.surveys.share.personal_links.expiry_date_optional")} + + + + + {t("environments.surveys.share.personal_links.expiry_date_description")} + + + )} + /> + + {/* Generate Button */} +
+
- {/* Expiry Date Section */} -
- -
- setExpiryDate(date)} - /> -
-

- {t("environments.surveys.summary.expiry_date_description")} -

-
- - {/* Generate Button */} - -
-
- - {/* Info Box */} - - {t("environments.surveys.summary.personal_links_work_with_segments")} - - - {t("common.learn_more")} - - - -
+ {/* Info Box */} + +
+
); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab.tsx index 123db23c24..fe639384ab 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab.tsx @@ -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"; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/share-view.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/share-view.test.tsx index e2a6305d02..a2370ea583 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/share-view.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/share-view.test.tsx @@ -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: () =>
AppTab Content
, })); -vi.mock("./EmailTab", () => ({ + +vi.mock("./email-tab", () => ({ EmailTab: (props: { surveyId: string; email: string }) => (
EmailTab Content for {props.surveyId} with {props.email}
), })); -vi.mock("./LinkTab", () => ({ - LinkTab: (props: { survey: any; surveyUrl: string }) => ( -
- LinkTab Content for {props.survey.id} at {props.surveyUrl} -
- ), -})); -vi.mock("./QRCodeTab", () => ({ + +vi.mock("./qr-code-tab", () => ({ QRCodeTab: (props: { surveyUrl: string }) => (
QRCodeTab Content for {props.surveyUrl}
), })); -vi.mock("./WebsiteTab", () => ({ - WebsiteTab: (props: { surveyUrl: string; environmentId: string }) => ( -
- WebsiteTab Content for {props.surveyUrl} in {props.environmentId} -
- ), -})); -vi.mock("./WebsiteEmbedTab", () => ({ +vi.mock("./website-embed-tab", () => ({ WebsiteEmbedTab: (props: { surveyUrl: string }) => (
WebsiteEmbedTab Content for {props.surveyUrl}
), })); -vi.mock("./DynamicPopupTab", () => ({ + +vi.mock("./dynamic-popup-tab", () => ({ DynamicPopupTab: (props: { environmentId: string; surveyId: string }) => (
DynamicPopupTab Content for {props.surveyId} in {props.environmentId}
), })); -vi.mock("./TabContainer", () => ({ + +vi.mock("./tab-container", () => ({ TabContainer: (props: { children: React.ReactNode; title: string; description: string }) => (
{props.title}
@@ -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; + }) => ( +
+ AnonymousLinksTab Content for {props.survey.id} at {props.surveyUrl} +
+ ), +})); + vi.mock("@/modules/ui/components/upgrade-prompt", () => ({ UpgradePrompt: (props: { title: string; description: string }) => (
@@ -81,25 +88,27 @@ vi.mock("@tolgee/react", () => ({ // Mock lucide-react vi.mock("lucide-react", () => ({ + CopyIcon: () =>
CopyIcon
, ArrowLeftIcon: () =>
ArrowLeftIcon
, + ArrowUpRightIcon: () =>
ArrowUpRightIcon
, MailIcon: () =>
MailIcon
, LinkIcon: () =>
LinkIcon
, GlobeIcon: () =>
GlobeIcon
, SmartphoneIcon: () =>
SmartphoneIcon
, CheckCircle2Icon: () =>
CheckCircle2Icon
, - AlertCircle: ({ className }: { className?: string }) => ( -
- AlertCircle + AlertCircleIcon: ({ className }: { className?: string }) => ( +
+ AlertCircleIcon
), - AlertTriangle: ({ className }: { className?: string }) => ( -
- AlertTriangle + AlertTriangleIcon: ({ className }: { className?: string }) => ( +
+ AlertTriangleIcon
), - Info: ({ className }: { className?: string }) => ( -
- Info + InfoIcon: ({ className }: { className?: string }) => ( +
+ InfoIcon
), 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: () =>
}, - { id: "website-embed", label: "Website Embed", icon: () =>
}, - { id: "dynamic-popup", label: "Dynamic Popup", icon: () =>
}, - { id: "link", label: "Link", icon: () =>
}, - { id: "qr-code", label: "QR Code", icon: () =>
}, - { id: "app", label: "App", icon: () =>
}, +const mockTabs: Array<{ id: ShareViewType; label: string; icon: React.ElementType }> = [ + { id: ShareViewType.EMAIL, label: "Email", icon: () =>
}, + { + id: ShareViewType.WEBSITE_EMBED, + label: "Website Embed", + icon: () =>
, + }, + { + id: ShareViewType.DYNAMIC_POPUP, + label: "Dynamic Popup", + icon: () =>
, + }, + { id: ShareViewType.ANON_LINKS, label: "Anonymous Links", icon: () =>
}, + { id: ShareViewType.QR_CODE, label: "QR Code", icon: () =>
}, + { id: ShareViewType.APP, label: "App", 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(); + render(); // 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(); + render(); 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(); + render(); 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(); - expect(screen.getByTestId("tab-container")).toBeInTheDocument(); + render(); 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(); - expect(screen.getByTestId("tab-container")).toBeInTheDocument(); + render(); 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(); - expect(screen.getByTestId("link-tab")).toBeInTheDocument(); + test("renders AnonymousLinksTab when activeId is 'anon-links'", () => { + render(); + 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(); + render(); expect(screen.getByTestId("qr-code-tab")).toBeInTheDocument(); }); test("renders AppTab when activeId is 'app'", () => { - render(); + render(); expect(screen.getByTestId("app-tab")).toBeInTheDocument(); }); test("renders PersonalLinksTab when activeId is 'personal-links'", () => { - render(); + render(); 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(); + render(); // 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(); + render(); 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(); + render(); // Get responsive buttons - these are Button components with ghost variant const responsiveButtons = screen.getAllByTestId("email-tab-icon"); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/share-view.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/share-view.tsx index f17f8cc1aa..58b8bb320f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/share-view.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/share-view.tsx @@ -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>; + tabs: Array<{ id: ShareViewType; label: string; icon: React.ElementType }>; + activeId: ShareViewType; + setActiveId: React.Dispatch>; 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 ; - case "website-embed": + case ShareViewType.WEBSITE_EMBED: + return ; + case ShareViewType.DYNAMIC_POPUP: + return ; + case ShareViewType.ANON_LINKS: return ( - - - - ); - case "dynamic-popup": - return ( - - - - ); - case "link": - return ( - ); - case "qr-code": - return ; - case "app": + case ShareViewType.APP: return ; - case "personal-links": + case ShareViewType.QR_CODE: + return ; + case ShareViewType.PERSONAL_LINKS: return ( - Share via + + {t("environments.surveys.share.share_view_title")} + diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/TabContainer.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container.test.tsx similarity index 98% rename from apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/TabContainer.test.tsx rename to apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container.test.tsx index e4faeefe8b..4f599dcb1e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/TabContainer.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container.test.tsx @@ -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", () => ({ diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/TabContainer.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container.tsx similarity index 94% rename from apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/TabContainer.tsx rename to apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container.tsx index 35720a3cfe..e1be63a7f0 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/TabContainer.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container.tsx @@ -9,7 +9,7 @@ interface TabContainerProps { export const TabContainer = ({ title, description, children }: TabContainerProps) => { return (
-
+

{title}

{description} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/WebsiteEmbedTab.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/website-embed-tab.test.tsx similarity index 92% rename from apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/WebsiteEmbedTab.test.tsx rename to apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/website-embed-tab.test.tsx index 2e580c4e64..a46c206e88 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/WebsiteEmbedTab.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/website-embed-tab.test.tsx @@ -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", () => ({ }) => (
{props.language} - {props.showCopyToClipboard.toString()} + {props.showCopyToClipboard?.toString() || "false"} {props.noMargin && true}
{props.children}
@@ -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(); 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"); }); }); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/WebsiteEmbedTab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/website-embed-tab.tsx similarity index 69% rename from apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/WebsiteEmbedTab.tsx rename to apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/website-embed-tab.tsx index 3480fe9c72..33b42f76c7 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/WebsiteEmbedTab.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/website-embed-tab.tsx @@ -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) => {
`; return ( - <> -
- - {iframeCode} - -
+ + + {iframeCode} + + - + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share.ts new file mode 100644 index 0000000000..7691f13742 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share.ts @@ -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", +} diff --git a/apps/web/lib/survey/service.ts b/apps/web/lib/survey/service.ts index 24d8c227f2..bb9f9b9818 100644 --- a/apps/web/lib/survey/service.ts +++ b/apps/web/lib/survey/service.ts @@ -557,8 +557,8 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise => 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); } diff --git a/apps/web/lib/time.test.ts b/apps/web/lib/time.test.ts index 9eae8ceb1d..5b17cd0b1a 100644 --- a/apps/web/lib/time.test.ts +++ b/apps/web/lib/time.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { convertDateString, convertDateTimeString, diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index e29e768022..1a717c6757 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -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.", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 2a89948054..9a20bce0be 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -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 don’t encrypt single-use ID’s, 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 don’t encrypt single-use ID’s, 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.", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 305010df47..b9f52329ee 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -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 n’encryptez 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 n’encryptez 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.", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 37338cfe41..921eedfd5b 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -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 ID’s 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 ID’s 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.", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 400a17d930..afccf18c2d 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -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.", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 9b14a0e124..455af00f7e 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -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": "問卷已完成的次數。", diff --git a/apps/web/modules/analysis/components/ShareSurveyLink/components/LanguageDropdown.tsx b/apps/web/modules/analysis/components/ShareSurveyLink/components/LanguageDropdown.tsx index 44d79cee52..a2ee9222b4 100644 --- a/apps/web/modules/analysis/components/ShareSurveyLink/components/LanguageDropdown.tsx +++ b/apps/web/modules/analysis/components/ShareSurveyLink/components/LanguageDropdown.tsx @@ -30,7 +30,7 @@ export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdo {enabledLanguages.map((surveyLanguage) => ( - - {survey.singleUse?.enabled && ( - - )}
); diff --git a/apps/web/modules/analysis/utils.tsx b/apps/web/modules/analysis/utils.tsx index 3f600157e0..70978d04c6 100644 --- a/apps/web/modules/analysis/utils.tsx +++ b/apps/web/modules/analysis/utils.tsx @@ -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 => { +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}`); } diff --git a/apps/web/modules/survey/hooks/useSingleUseId.test.tsx b/apps/web/modules/survey/hooks/useSingleUseId.test.tsx index 11fb383db1..3250d724c6 100644 --- a/apps/web/modules/survey/hooks/useSingleUseId.test.tsx +++ b/apps/web/modules/survey/hooks/useSingleUseId.test.tsx @@ -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, }); }); }); diff --git a/apps/web/modules/survey/hooks/useSingleUseId.tsx b/apps/web/modules/survey/hooks/useSingleUseId.tsx index b506e20d5f..cd91311316 100644 --- a/apps/web/modules/survey/hooks/useSingleUseId.tsx +++ b/apps/web/modules/survey/hooks/useSingleUseId.tsx @@ -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); diff --git a/apps/web/modules/survey/list/actions.ts b/apps/web/modules/survey/list/actions.ts index 3edaed7f03..70876fe58b 100644 --- a/apps/web/modules/survey/list/actions.ts +++ b/apps/web/modules/survey/list/actions.ts @@ -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({ diff --git a/apps/web/modules/ui/components/advanced-option-toggle/index.tsx b/apps/web/modules/ui/components/advanced-option-toggle/index.tsx index 17206760ac..23f102bedc 100644 --- a/apps/web/modules/ui/components/advanced-option-toggle/index.tsx +++ b/apps/web/modules/ui/components/advanced-option-toggle/index.tsx @@ -38,9 +38,10 @@ export const AdvancedOptionToggle = ({
{children && isChecked && (
+ className={cn( + "mt-4 flex w-full items-center space-x-1 overflow-hidden rounded-lg bg-slate-50", + childBorder && "border" + )}> {children}
)} diff --git a/apps/web/modules/ui/components/alert/index.tsx b/apps/web/modules/ui/components/alert/index.tsx index b40ec6f95d..02d94ab6b1 100644 --- a/apps/web/modules/ui/components/alert/index.tsx +++ b/apps/web/modules/ui/components/alert/index.tsx @@ -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({ 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: , - warning: , - info: , + outbound: , + error: , + warning: , + info: , success: , }; @@ -140,4 +151,4 @@ const AlertButton = React.forwardRef( AlertButton.displayName = "AlertButton"; // Export the new component -export { Alert, AlertTitle, AlertDescription, AlertButton }; +export { Alert, AlertButton, AlertDescription, AlertTitle }; diff --git a/apps/web/modules/ui/components/code-block/index.tsx b/apps/web/modules/ui/components/code-block/index.tsx index 2be2420510..30c40abd27 100644 --- a/apps/web/modules/ui/components/code-block/index.tsx +++ b/apps/web/modules/ui/components/code-block/index.tsx @@ -32,7 +32,7 @@ export const CodeBlock = ({ }, [children]); return ( -
+
{showCopyToClipboard && (
)} -
+      
         {children}
       
diff --git a/apps/web/modules/ui/components/date-picker/index.tsx b/apps/web/modules/ui/components/date-picker/index.tsx index 780e5711cd..fe6b8a7e1e 100644 --- a/apps/web/modules/ui/components/date-picker/index.tsx +++ b/apps/web/modules/ui/components/date-picker/index.tsx @@ -69,7 +69,7 @@ export const DatePicker = ({ date, updateSurveyDate, minDate, onClearDate }: Dat