Merge remote-tracking branch 'origin/main' into harsh/plain-integration

This commit is contained in:
harshsbhat
2025-07-15 20:10:07 +05:30
57 changed files with 1586 additions and 927 deletions

View File

@@ -1,348 +1,314 @@
import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
import { cleanup, render, screen } from "@testing-library/react";
import { ShareViewType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LucideIcon } from "lucide-react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
TSurvey,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveySingleUse,
} from "@formbricks/types/surveys/types";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { ShareSurveyModal } from "./share-survey-modal";
// Mock data
const mockSurveyWeb = {
id: "survey1",
name: "Web Survey",
environmentId: "env1",
type: "app",
status: "inProgress",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Q1" },
required: true,
} as unknown as TSurveyQuestion,
],
displayOption: "displayOnce",
recontactDays: 0,
autoClose: null,
delay: 0,
autoComplete: null,
runOnDate: null,
closeOnDate: null,
singleUse: { enabled: false, isEncrypted: false } as TSurveySingleUse,
triggers: [],
createdAt: new Date(),
updatedAt: new Date(),
languages: [],
styling: null,
} as unknown as TSurvey;
vi.mock("@/lib/constants", () => ({
INTERCOM_SECRET_KEY: "test-secret-key",
IS_INTERCOM_CONFIGURED: true,
INTERCOM_APP_ID: "test-app-id",
ENCRYPTION_KEY: "test-encryption-key",
ENTERPRISE_LICENSE_KEY: "test-enterprise-license-key",
GITHUB_ID: "test-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_POSTHOG_CONFIGURED: true,
POSTHOG_API_HOST: "test-posthog-api-host",
POSTHOG_API_KEY: "test-posthog-api-key",
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
IS_FORMBRICKS_CLOUD: false,
// Mock child components to simplify testing
vi.mock("./shareEmbedModal/share-view", () => ({
ShareView: ({ tabs, activeId, setActiveId, survey }: any) => (
<div data-testid="share-view">
<div data-testid="survey-type">{survey.type}</div>
<div data-testid="active-tab">{activeId}</div>
{tabs.map((tab: any) => (
<button key={tab.id} data-testid={`tab-${tab.id}`} onClick={() => setActiveId(tab.id)}>
{tab.label}
</button>
))}
</div>
),
}));
const mockSurveyLink = {
...mockSurveyWeb,
id: "survey2",
name: "Link Survey",
type: "link",
singleUse: { enabled: false, isEncrypted: false } as TSurveySingleUse,
} as unknown as TSurvey;
vi.mock("./shareEmbedModal/success-view", () => ({
SuccessView: ({ survey, handleViewChange, handleEmbedViewWithTab }: any) => (
<div data-testid="success-view">
<div data-testid="survey-id">{survey.id}</div>
<button data-testid="change-to-share-view" onClick={() => handleViewChange("share")}>
Go to Share View
</button>
<button data-testid="embed-with-tab" onClick={() => handleEmbedViewWithTab("email")}>
Embed with Email Tab
</button>
</div>
),
}));
// Mock tab components
vi.mock("./shareEmbedModal/anonymous-links-tab", () => ({
AnonymousLinksTab: () => <div data-testid="anonymous-links-tab">Anonymous Links Tab</div>,
}));
vi.mock("./shareEmbedModal/qr-code-tab", () => ({
QRCodeTab: () => <div data-testid="qr-code-tab">QR Code Tab</div>,
}));
vi.mock("./shareEmbedModal/personal-links-tab", () => ({
PersonalLinksTab: () => <div data-testid="personal-links-tab">Personal Links Tab</div>,
}));
vi.mock("./shareEmbedModal/email-tab", () => ({
EmailTab: () => <div data-testid="email-tab">Email Tab</div>,
}));
vi.mock("./shareEmbedModal/website-embed-tab", () => ({
WebsiteEmbedTab: () => <div data-testid="website-embed-tab">Website Embed Tab</div>,
}));
vi.mock("./shareEmbedModal/social-media-tab", () => ({
SocialMediaTab: () => <div data-testid="social-media-tab">Social Media Tab</div>,
}));
vi.mock("./shareEmbedModal/dynamic-popup-tab", () => ({
DynamicPopupTab: () => <div data-testid="dynamic-popup-tab">Dynamic Popup Tab</div>,
}));
vi.mock("./shareEmbedModal/app-tab", () => ({
AppTab: () => <div data-testid="app-tab">App Tab</div>,
}));
// Mock analysis utils
vi.mock("@/modules/analysis/utils", () => ({
getSurveyUrl: vi.fn((survey, publicDomain, type) => `${publicDomain}/${survey.id}?type=${type}`),
}));
const mockUser = {
id: "user1",
name: "Test User",
id: "user-123",
email: "test@example.com",
role: "project_manager",
objective: "other",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test User",
locale: "en-US",
} as unknown as TUser;
} as TUser;
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (str: string) => str,
}),
}));
const mockSegments: TSegment[] = [
{
id: "segment-1",
title: "Test Segment",
description: "Test segment description",
environmentId: "env-123",
filters: [],
isPrivate: false,
surveys: [],
createdAt: new Date(),
updatedAt: new Date(),
},
];
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
ShareSurveyLink: vi.fn(() => <div>ShareSurveyLinkMock</div>),
}));
const mockLinkSurvey = {
id: "survey-123",
name: "Test Link Survey",
type: "link",
environmentId: "env-123",
status: "draft",
} as TSurvey;
vi.mock("@/modules/ui/components/badge", () => ({
Badge: vi.fn(({ text }) => <span data-testid="badge-mock">{text}</span>),
}));
const mockShareViewComponent = vi.fn();
vi.mock("./shareEmbedModal/share-view", () => ({
ShareView: (props: any) => mockShareViewComponent(props),
}));
// Mock getSurveyUrl to return a predictable URL
vi.mock("@/modules/analysis/utils", () => ({
getSurveyUrl: vi.fn().mockResolvedValue("https://public-domain.com/s/survey1"),
}));
let capturedDialogOnOpenChange: ((open: boolean) => void) | undefined;
vi.mock("@/modules/ui/components/dialog", async () => {
const actual = await vi.importActual<typeof import("@/modules/ui/components/dialog")>(
"@/modules/ui/components/dialog"
);
return {
...actual,
Dialog: (props: React.ComponentProps<typeof actual.Dialog>) => {
capturedDialogOnOpenChange = props.onOpenChange;
return <actual.Dialog {...props} />;
},
};
});
describe("ShareEmbedSurvey", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
capturedDialogOnOpenChange = undefined;
});
const mockSetOpen = vi.fn();
const mockAppSurvey = {
id: "app-survey-123",
name: "Test App Survey",
type: "app",
environmentId: "env-123",
status: "draft",
} as TSurvey;
describe("ShareSurveyModal", () => {
const defaultProps = {
survey: mockSurveyWeb,
publicDomain: "https://public-domain.com",
publicDomain: "https://formbricks.com",
open: true,
modalView: "start" as "start" | "share",
setOpen: mockSetOpen,
modalView: "start" as const,
setOpen: vi.fn(),
user: mockUser,
segments: [],
segments: mockSegments,
isContactsEnabled: true,
isFormbricksCloud: true,
};
beforeEach(() => {
mockShareViewComponent.mockImplementation(
({ tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => (
<div>
<div data-testid="shareview-tabs">{JSON.stringify(tabs)}</div>
<div data-testid="shareview-activeid">{activeId}</div>
<div data-testid="shareview-survey-id">{survey.id}</div>
<div data-testid="shareview-email">{email}</div>
<div data-testid="shareview-surveyUrl">{surveyUrl}</div>
<div data-testid="shareview-publicDomain">{publicDomain}</div>
<div data-testid="shareview-locale">{locale}</div>
</div>
)
);
vi.clearAllMocks();
});
test("renders initial 'start' view correctly when open and modalView is 'start' for link survey", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockSurveyLink} />);
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.getByText("ShareSurveyLinkMock")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.share_survey")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.configure_alerts")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.setup_integrations")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.use_personal_links")).toBeInTheDocument();
expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new");
});
test("renders initial 'start' view correctly when open and modalView is 'start' for app survey", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockSurveyWeb} />);
// For app surveys, ShareSurveyLink should not be rendered
expect(screen.queryByText("ShareSurveyLinkMock")).not.toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.share_survey")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.configure_alerts")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.setup_integrations")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.use_personal_links")).toBeInTheDocument();
expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new");
});
test("switches to 'embed' view when 'Embed survey' button is clicked", async () => {
render(<ShareSurveyModal {...defaultProps} />);
const embedButton = screen.getByText("environments.surveys.summary.share_survey");
await userEvent.click(embedButton);
expect(mockShareViewComponent).toHaveBeenCalled();
expect(screen.getByTestId("shareview-tabs")).toBeInTheDocument();
});
test("handleOpenChange (when Dialog calls its onOpenChange prop)", () => {
render(<ShareSurveyModal {...defaultProps} open={true} survey={mockSurveyWeb} />);
expect(capturedDialogOnOpenChange).toBeDefined();
// Simulate Dialog closing
if (capturedDialogOnOpenChange) capturedDialogOnOpenChange(false);
expect(mockSetOpen).toHaveBeenCalledWith(false);
// Simulate Dialog opening
mockSetOpen.mockClear();
if (capturedDialogOnOpenChange) capturedDialogOnOpenChange(true);
expect(mockSetOpen).toHaveBeenCalledWith(true);
});
test("correctly configures for 'anon-links' survey type in embed view", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockSurveyLink} modalView="share" />);
const embedViewProps = vi.mocked(mockShareViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string; icon: LucideIcon }[];
activeId: string;
};
expect(embedViewProps.tabs.length).toBe(6);
expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeUndefined();
expect(embedViewProps.tabs.find((tab) => tab.id === "dynamic-popup")).toBeDefined();
expect(embedViewProps.tabs.find((tab) => tab.id === "website-embed")).toBeDefined();
expect(embedViewProps.tabs[0].id).toBe("anon-links");
expect(embedViewProps.tabs[1].id).toBe("qr-code");
expect(embedViewProps.tabs[2].id).toBe("personal-links");
expect(embedViewProps.tabs[3].id).toBe("email");
expect(embedViewProps.tabs[4].id).toBe("website-embed");
expect(embedViewProps.activeId).toBe("anon-links");
});
test("correctly configures for 'web' survey type in embed view", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockSurveyWeb} modalView="share" />);
const embedViewProps = vi.mocked(mockShareViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string; icon: LucideIcon }[];
activeId: string;
};
expect(embedViewProps.tabs.length).toBe(1);
expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeDefined();
expect(embedViewProps.tabs.find((tab) => tab.id === "website-embed")).toBeUndefined();
expect(embedViewProps.tabs.find((tab) => tab.id === "dynamic-popup")).toBeUndefined();
expect(embedViewProps.activeId).toBe("app");
});
test("useEffect does not change activeId if survey.type changes from web to link (while in embed view)", () => {
const { rerender } = render(
<ShareSurveyModal {...defaultProps} survey={mockSurveyWeb} modalView="share" />
);
expect(vi.mocked(mockShareViewComponent).mock.calls[0][0].activeId).toBe("app");
rerender(<ShareSurveyModal {...defaultProps} survey={mockSurveyLink} modalView="share" />);
expect(vi.mocked(mockShareViewComponent).mock.calls[1][0].activeId).toBe("app"); // Current behavior
});
test("initial showView is set by modalView prop when open is true", () => {
render(<ShareSurveyModal {...defaultProps} open={true} modalView="share" />);
expect(mockShareViewComponent).toHaveBeenCalled();
expect(screen.getByTestId("shareview-tabs")).toBeInTheDocument();
afterEach(() => {
cleanup();
render(<ShareSurveyModal {...defaultProps} open={true} modalView="start" />);
// Start view shows the share survey button
expect(screen.getByText("environments.surveys.summary.share_survey")).toBeInTheDocument();
});
test("useEffect sets showView to 'start' when open becomes false", () => {
const { rerender } = render(<ShareSurveyModal {...defaultProps} open={true} modalView="share" />);
expect(screen.getByTestId("shareview-tabs")).toBeInTheDocument(); // Starts in embed
describe("Modal rendering and basic functionality", () => {
test("renders modal when open is true", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockLinkSurvey} />);
rerender(<ShareSurveyModal {...defaultProps} open={false} modalView="share" />);
// Dialog mock returns null when open is false, so EmbedViewMockContent is not found
expect(screen.queryByTestId("shareview-tabs")).not.toBeInTheDocument();
expect(screen.getByTestId("success-view")).toBeInTheDocument();
});
test("does not render modal content when open is false", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockLinkSurvey} open={false} />);
expect(screen.queryByTestId("success-view")).not.toBeInTheDocument();
expect(screen.queryByTestId("share-view")).not.toBeInTheDocument();
});
test("calls setOpen when modal is closed", async () => {
const mockSetOpen = vi.fn();
render(<ShareSurveyModal {...defaultProps} survey={mockLinkSurvey} setOpen={mockSetOpen} />);
// Simulate modal close by pressing escape
await userEvent.keyboard("{Escape}");
await waitFor(() => {
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
});
test("renders correct label for link tab based on singleUse survey property", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockSurveyLink} modalView="share" />);
let embedViewProps = vi.mocked(mockShareViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string }[];
};
let linkTab = embedViewProps.tabs.find((tab) => tab.id === "anon-links");
expect(linkTab?.label).toBe("environments.surveys.share.anonymous_links.nav_title");
cleanup();
vi.mocked(mockShareViewComponent).mockClear();
describe("View switching functionality", () => {
test("starts with SuccessView when modalView is 'start'", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockLinkSurvey} modalView="start" />);
const mockSurveyLinkSingleUse: TSurvey = {
...mockSurveyLink,
singleUse: { enabled: true, isEncrypted: true },
};
render(<ShareSurveyModal {...defaultProps} survey={mockSurveyLinkSingleUse} modalView="share" />);
embedViewProps = vi.mocked(mockShareViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string }[];
};
linkTab = embedViewProps.tabs.find((tab) => tab.id === "anon-links");
expect(linkTab?.label).toBe("environments.surveys.share.anonymous_links.nav_title");
expect(screen.getByTestId("success-view")).toBeInTheDocument();
expect(screen.queryByTestId("share-view")).not.toBeInTheDocument();
});
test("starts with ShareView when modalView is 'share'", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockLinkSurvey} modalView="share" />);
expect(screen.getByTestId("share-view")).toBeInTheDocument();
expect(screen.queryByTestId("success-view")).not.toBeInTheDocument();
});
test("switches from SuccessView to ShareView when button is clicked", async () => {
render(<ShareSurveyModal {...defaultProps} survey={mockLinkSurvey} modalView="start" />);
expect(screen.getByTestId("success-view")).toBeInTheDocument();
const changeViewButton = screen.getByTestId("change-to-share-view");
await userEvent.click(changeViewButton);
await waitFor(() => {
expect(screen.getByTestId("share-view")).toBeInTheDocument();
expect(screen.queryByTestId("success-view")).not.toBeInTheDocument();
});
});
test("switches to ShareView with specific tab when handleEmbedViewWithTab is called", async () => {
render(<ShareSurveyModal {...defaultProps} survey={mockLinkSurvey} modalView="start" />);
const embedButton = screen.getByTestId("embed-with-tab");
await userEvent.click(embedButton);
await waitFor(() => {
expect(screen.getByTestId("share-view")).toBeInTheDocument();
expect(screen.getByTestId("active-tab")).toHaveTextContent("email");
});
});
});
test("includes QR code tab for link surveys", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockSurveyLink} modalView="share" />);
const embedViewProps = vi.mocked(mockShareViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string }[];
};
const qrCodeTab = embedViewProps.tabs.find((tab) => tab.id === "qr-code");
expect(qrCodeTab).toBeDefined();
expect(qrCodeTab?.label).toBe("environments.surveys.summary.qr_code");
describe("Survey type specific behavior", () => {
test("displays link survey tabs for link type survey", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockLinkSurvey} modalView="share" />);
expect(screen.getByTestId("survey-type")).toHaveTextContent("link");
expect(screen.getByTestId("tab-anon-links")).toBeInTheDocument();
expect(screen.getByTestId("tab-personal-links")).toBeInTheDocument();
expect(screen.getByTestId("tab-website-embed")).toBeInTheDocument();
expect(screen.getByTestId("tab-email")).toBeInTheDocument();
expect(screen.getByTestId("tab-social-media")).toBeInTheDocument();
expect(screen.getByTestId("tab-qr-code")).toBeInTheDocument();
expect(screen.getByTestId("tab-dynamic-popup")).toBeInTheDocument();
});
test("displays app survey tabs for app type survey", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockAppSurvey} modalView="share" />);
expect(screen.getByTestId("survey-type")).toHaveTextContent("app");
expect(screen.getByTestId("tab-app")).toBeInTheDocument();
// Link-specific tabs should not be present for app surveys
expect(screen.queryByTestId("tab-anonymous_links")).not.toBeInTheDocument();
expect(screen.queryByTestId("tab-personal_links")).not.toBeInTheDocument();
});
test("sets correct default active tab based on survey type", () => {
const linkSurveyRender = render(
<ShareSurveyModal {...defaultProps} survey={mockLinkSurvey} modalView="share" />
);
expect(screen.getByTestId("active-tab")).toHaveTextContent(ShareViewType.ANON_LINKS);
linkSurveyRender.unmount();
render(<ShareSurveyModal {...defaultProps} survey={mockAppSurvey} modalView="share" />);
expect(screen.getByTestId("active-tab")).toHaveTextContent(ShareViewType.APP);
});
});
test("does not include QR code tab for app surveys", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockSurveyWeb} modalView="share" />);
const embedViewProps = vi.mocked(mockShareViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string }[];
};
const qrCodeTab = embedViewProps.tabs.find((tab) => tab.id === "qr-code");
expect(qrCodeTab).toBeUndefined();
describe("Tab switching functionality", () => {
test("switches active tab when tab button is clicked", async () => {
render(<ShareSurveyModal {...defaultProps} survey={mockLinkSurvey} modalView="share" />);
expect(screen.getByTestId("active-tab")).toHaveTextContent(ShareViewType.ANON_LINKS);
const emailTab = screen.getByTestId("tab-email");
await userEvent.click(emailTab);
await waitFor(() => {
expect(screen.getByTestId("active-tab")).toHaveTextContent(ShareViewType.EMAIL);
});
});
});
test("dynamic popup tab is only visible for link surveys", () => {
// Test link survey includes dynamic popup tab
render(<ShareSurveyModal {...defaultProps} survey={mockSurveyLink} modalView="share" />);
let embedViewProps = vi.mocked(mockShareViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string }[];
};
expect(embedViewProps.tabs.find((tab) => tab.id === "dynamic-popup")).toBeDefined();
cleanup();
vi.mocked(mockShareViewComponent).mockClear();
describe("Props passing", () => {
test("passes correct props to SuccessView", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockLinkSurvey} modalView="start" />);
// Test web survey excludes dynamic popup tab
render(<ShareSurveyModal {...defaultProps} survey={mockSurveyWeb} modalView="share" />);
embedViewProps = vi.mocked(mockShareViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string }[];
};
expect(embedViewProps.tabs.find((tab) => tab.id === "dynamic-popup")).toBeUndefined();
expect(screen.getByTestId("survey-id")).toHaveTextContent(mockLinkSurvey.id);
});
test("passes correct props to ShareView", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockLinkSurvey} modalView="share" />);
expect(screen.getByTestId("survey-type")).toHaveTextContent(mockLinkSurvey.type);
expect(screen.getByTestId("active-tab")).toHaveTextContent(ShareViewType.ANON_LINKS);
});
});
render(<ShareSurveyModal {...defaultProps} survey={mockSurveyLink} modalView="share" />);
const embedViewProps = vi.mocked(mockShareViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string }[];
};
test("QR code tab appears after link tab in the tabs array", () => {
const linkTabIndex = embedViewProps.tabs.findIndex((tab) => tab.id === "anon-links");
const qrCodeTabIndex = embedViewProps.tabs.findIndex((tab) => tab.id === "qr-code");
expect(qrCodeTabIndex).toBe(linkTabIndex + 1);
describe("URL handling", () => {
test("initializes survey URL correctly", async () => {
const { getSurveyUrl } = await import("@/modules/analysis/utils");
const getSurveyUrlMock = vi.mocked(getSurveyUrl);
render(<ShareSurveyModal {...defaultProps} survey={mockLinkSurvey} />);
expect(getSurveyUrlMock).toHaveBeenCalledWith(mockLinkSurvey, defaultProps.publicDomain, "default");
});
});
test("website-embed and dynamic-popup tabs replace old webpage tab", () => {
expect(embedViewProps.tabs.find((tab) => tab.id === "webpage")).toBeUndefined();
expect(embedViewProps.tabs.find((tab) => tab.id === "website-embed")).toBeDefined();
expect(embedViewProps.tabs.find((tab) => tab.id === "dynamic-popup")).toBeDefined();
describe("Effect handling", () => {
test("updates showView when modalView prop changes", async () => {
const { rerender } = render(
<ShareSurveyModal {...defaultProps} survey={mockLinkSurvey} modalView="start" />
);
expect(screen.getByTestId("success-view")).toBeInTheDocument();
rerender(<ShareSurveyModal {...defaultProps} survey={mockLinkSurvey} modalView="share" />);
await waitFor(() => {
expect(screen.getByTestId("share-view")).toBeInTheDocument();
});
});
test("updates showView when open prop changes", async () => {
const { rerender } = render(
<ShareSurveyModal {...defaultProps} survey={mockLinkSurvey} modalView="start" open={false} />
);
expect(screen.queryByTestId("success-view")).not.toBeInTheDocument();
rerender(<ShareSurveyModal {...defaultProps} survey={mockLinkSurvey} modalView="start" open={true} />);
await waitFor(() => {
expect(screen.getByTestId("success-view")).toBeInTheDocument();
});
});
});
});

View File

@@ -1,5 +1,13 @@
"use client";
import { AnonymousLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab";
import { AppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab";
import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab";
import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab";
import { PersonalLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab";
import { QRCodeTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab";
import { SocialMediaTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab";
import { WebsiteEmbedTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/website-embed-tab";
import { ShareViewType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
import { getSurveyUrl } from "@/modules/analysis/utils";
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
@@ -10,6 +18,7 @@ import {
LinkIcon,
MailIcon,
QrCodeIcon,
Share2Icon,
SmartphoneIcon,
SquareStack,
UserIcon,
@@ -47,42 +56,109 @@ export const ShareSurveyModal = ({
isFormbricksCloud,
}: ShareSurveyModalProps) => {
const environmentId = survey.environmentId;
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
const [showView, setShowView] = useState<ModalView>(modalView);
const { email } = user;
const { t } = useTranslate();
const linkTabs: { id: ShareViewType; label: string; icon: React.ElementType }[] = useMemo(
const linkTabs: {
id: ShareViewType;
label: string;
icon: React.ElementType;
title: string;
description: string;
componentType: React.ComponentType<any>;
componentProps: any;
}[] = useMemo(
() => [
{
id: ShareViewType.ANON_LINKS,
label: t("environments.surveys.share.anonymous_links.nav_title"),
icon: LinkIcon,
},
{
id: ShareViewType.QR_CODE,
label: t("environments.surveys.summary.qr_code"),
icon: QrCodeIcon,
title: t("environments.surveys.share.anonymous_links.nav_title"),
description: t("environments.surveys.share.anonymous_links.description"),
componentType: AnonymousLinksTab,
componentProps: {
survey,
publicDomain,
setSurveyUrl,
locale: user.locale,
surveyUrl,
},
},
{
id: ShareViewType.PERSONAL_LINKS,
label: t("environments.surveys.share.personal_links.nav_title"),
icon: UserIcon,
},
{
id: ShareViewType.EMAIL,
label: t("environments.surveys.share.send_email.nav_title"),
icon: MailIcon,
title: t("environments.surveys.share.personal_links.nav_title"),
description: t("environments.surveys.share.personal_links.description"),
componentType: PersonalLinksTab,
componentProps: {
environmentId,
surveyId: survey.id,
segments,
isContactsEnabled,
isFormbricksCloud,
},
},
{
id: ShareViewType.WEBSITE_EMBED,
label: t("environments.surveys.share.embed_on_website.nav_title"),
icon: Code2Icon,
title: t("environments.surveys.share.embed_on_website.nav_title"),
description: t("environments.surveys.share.embed_on_website.description"),
componentType: WebsiteEmbedTab,
componentProps: { surveyUrl },
},
{
id: ShareViewType.EMAIL,
label: t("environments.surveys.share.send_email.nav_title"),
icon: MailIcon,
title: t("environments.surveys.share.send_email.nav_title"),
description: t("environments.surveys.share.send_email.description"),
componentType: EmailTab,
componentProps: { surveyId: survey.id, email },
},
{
id: ShareViewType.SOCIAL_MEDIA,
label: t("environments.surveys.share.social_media.title"),
icon: Share2Icon,
title: t("environments.surveys.share.social_media.title"),
description: t("environments.surveys.share.social_media.description"),
componentType: SocialMediaTab,
componentProps: { surveyUrl, surveyTitle: survey.name },
},
{
id: ShareViewType.QR_CODE,
label: t("environments.surveys.summary.qr_code"),
icon: QrCodeIcon,
title: t("environments.surveys.summary.qr_code"),
description: t("environments.surveys.summary.qr_code_description"),
componentType: QRCodeTab,
componentProps: { surveyUrl },
},
{
id: ShareViewType.DYNAMIC_POPUP,
label: t("environments.surveys.share.dynamic_popup.nav_title"),
icon: SquareStack,
title: t("environments.surveys.share.dynamic_popup.nav_title"),
description: t("environments.surveys.share.dynamic_popup.description"),
componentType: DynamicPopupTab,
componentProps: { environmentId, surveyId: survey.id },
},
],
[t]
[
t,
survey,
publicDomain,
setSurveyUrl,
user.locale,
surveyUrl,
environmentId,
segments,
isContactsEnabled,
isFormbricksCloud,
email,
]
);
const appTabs = [
@@ -90,6 +166,9 @@ export const ShareSurveyModal = ({
id: ShareViewType.APP,
label: t("environments.surveys.share.embed_on_website.embed_in_app"),
icon: SmartphoneIcon,
title: t("environments.surveys.share.embed_on_website.embed_in_app"),
componentType: AppTab,
componentProps: {},
},
];
@@ -97,9 +176,6 @@ export const ShareSurveyModal = ({
survey.type === "link" ? ShareViewType.ANON_LINKS : ShareViewType.APP
);
const [surveyUrl, setSurveyUrl] = useState(() => getSurveyUrl(survey, publicDomain, "default"));
const [showView, setShowView] = useState<ModalView>(modalView);
useEffect(() => {
if (open) {
setShowView(modalView);
@@ -148,17 +224,8 @@ export const ShareSurveyModal = ({
<ShareView
tabs={survey.type === "link" ? linkTabs : appTabs}
activeId={activeId}
environmentId={environmentId}
setActiveId={setActiveId}
survey={survey}
email={email}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/>
)}
</DialogContent>

View File

@@ -192,14 +192,6 @@ describe("AnonymousLinksTab", () => {
cleanup();
});
test("renders with multi-use link enabled by default", () => {
render(<AnonymousLinksTab {...defaultProps} />);
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
expect(screen.getByTestId("toggle-multi-use-link-switch")).toHaveAttribute("data-checked", "true");
expect(screen.getByTestId("toggle-single-use-link-switch")).toHaveAttribute("data-checked", "false");
});
test("renders with single-use link enabled when survey has singleUse enabled", () => {
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);

View File

@@ -3,7 +3,6 @@
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";
@@ -205,9 +204,7 @@ export const AnonymousLinksTab = ({
};
return (
<TabContainer
title={t("environments.surveys.share.anonymous_links.title")}
description={t("environments.surveys.share.anonymous_links.description")}>
<>
<div className="flex h-full w-full grow flex-col gap-6">
<AdvancedOptionToggle
htmlId="multi-use-link-switch"
@@ -341,6 +338,6 @@ export const AnonymousLinksTab = ({
}}
/>
)}
</TabContainer>
</>
);
};

View File

@@ -11,7 +11,7 @@ export const AppTab = () => {
const [selectedTab, setSelectedTab] = useState("webapp");
return (
<div className="flex h-full grow flex-col">
<div className="flex h-full w-full grow flex-col">
<OptionsSwitch
options={[
{ value: "webapp", label: t("environments.surveys.summary.web_app") },

View File

@@ -112,14 +112,6 @@ describe("DynamicPopupTab", () => {
expect(link).toHaveTextContent("environments.surveys.share.dynamic_popup.alert_button");
});
test("renders title with correct text", () => {
render(<DynamicPopupTab {...defaultProps} />);
const h3 = screen.getByTestId("h3");
expect(h3).toBeInTheDocument();
expect(h3).toHaveTextContent("environments.surveys.share.dynamic_popup.title");
});
test("renders attribute-based targeting documentation button", () => {
render(<DynamicPopupTab {...defaultProps} />);

View File

@@ -1,7 +1,6 @@
"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";
@@ -15,39 +14,33 @@ export const DynamicPopupTab = ({ environmentId, surveyId }: DynamicPopupTabProp
const { t } = useTranslate();
return (
<TabContainer
title={t("environments.surveys.share.dynamic_popup.title")}
description={t("environments.surveys.share.dynamic_popup.description")}>
<div className="flex h-full flex-col justify-between space-y-4">
<Alert variant="info" size="default">
<AlertTitle>{t("environments.surveys.share.dynamic_popup.alert_title")}</AlertTitle>
<AlertDescription>
{t("environments.surveys.share.dynamic_popup.alert_description")}
</AlertDescription>
<AlertButton asChild>
<Link href={`/environments/${environmentId}/surveys/${surveyId}/edit`}>
{t("environments.surveys.share.dynamic_popup.alert_button")}
</Link>
</AlertButton>
</Alert>
<div className="flex h-full flex-col justify-between space-y-4">
<Alert variant="info" size="default">
<AlertTitle>{t("environments.surveys.share.dynamic_popup.alert_title")}</AlertTitle>
<AlertDescription>{t("environments.surveys.share.dynamic_popup.alert_description")}</AlertDescription>
<AlertButton asChild>
<Link href={`/environments/${environmentId}/surveys/${surveyId}/edit`}>
{t("environments.surveys.share.dynamic_popup.alert_button")}
</Link>
</AlertButton>
</Alert>
<DocumentationLinks
links={[
{
title: t("environments.surveys.share.dynamic_popup.attribute_based_targeting"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting",
},
{
title: t("environments.surveys.share.dynamic_popup.code_no_code_triggers"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions",
},
{
title: t("environments.surveys.share.dynamic_popup.recontact_options"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/recontact",
},
]}
/>
</div>
</TabContainer>
<DocumentationLinks
links={[
{
title: t("environments.surveys.share.dynamic_popup.attribute_based_targeting"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting",
},
{
title: t("environments.surveys.share.dynamic_popup.code_no_code_triggers"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions",
},
{
title: t("environments.surveys.share.dynamic_popup.recontact_options"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/recontact",
},
]}
/>
</div>
);
};

View File

@@ -1,6 +1,5 @@
"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";
@@ -142,19 +141,15 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
};
return (
<TabContainer
title={t("environments.surveys.share.send_email.title")}
description={t("environments.surveys.share.send_email.description")}>
<div className="flex h-full w-full flex-col space-y-4">
<TabBar
tabs={tabs}
activeId={activeTab}
setActiveId={setActiveTab}
tabStyle="button"
className="h-10 min-h-10 rounded-md border border-slate-200 bg-slate-100"
/>
<div className="flex-1">{renderTabContent()}</div>
</div>
</TabContainer>
<div className="flex h-full w-full flex-col space-y-4">
<TabBar
tabs={tabs}
activeId={activeTab}
setActiveId={setActiveTab}
tabStyle="button"
className="h-10 min-h-10 rounded-md border border-slate-200 bg-slate-100"
/>
<div className="flex-1">{renderTabContent()}</div>
</div>
);
};

View File

@@ -192,13 +192,6 @@ describe("PersonalLinksTab", () => {
cleanup();
});
test("renders the component with correct title and description", () => {
render(<PersonalLinksTab {...defaultProps} />);
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", () => {
render(<PersonalLinksTab {...defaultProps} />);

View File

@@ -27,7 +27,6 @@ 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;
@@ -169,86 +168,82 @@ export const PersonalLinksTab = ({
return (
<FormProvider {...form}>
<TabContainer
title={t("environments.surveys.share.personal_links.title")}
description={t("environments.surveys.share.personal_links.description")}>
<div className="flex h-full grow flex-col gap-6">
{/* Recipients Section */}
<FormField
control={form.control}
name="selectedSegment"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common.recipients")}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={publicSegments.length === 0}>
<SelectTrigger className="w-full bg-white">
<SelectValue
placeholder={
publicSegments.length === 0
? t("environments.surveys.share.personal_links.no_segments_available")
: t("environments.surveys.share.personal_links.select_segment")
}
/>
</SelectTrigger>
<SelectContent>
{publicSegments.map((segment) => (
<SelectItem key={segment.id} value={segment.id}>
{segment.title}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
{t("environments.surveys.share.personal_links.create_and_manage_segments")}
</FormDescription>
</FormItem>
)}
/>
{/* Expiry Date Section */}
<FormField
control={form.control}
name="expiryDate"
render={({ field }) => (
<FormItem>
<FormLabel>{t("environments.surveys.share.personal_links.expiry_date_optional")}</FormLabel>
<FormControl>
<RestrictedDatePicker date={field.value} updateSurveyDate={field.onChange} />
</FormControl>
<FormDescription>
{t("environments.surveys.share.personal_links.expiry_date_description")}
</FormDescription>
</FormItem>
)}
/>
{/* Generate Button */}
<Button
onClick={handleGenerateLinks}
disabled={isButtonDisabled}
loading={isGenerating}
className="w-fit">
<DownloadIcon className="mr-2 h-4 w-4" />
{buttonText}
</Button>
</div>
<hr />
{/* Info Box */}
<DocumentationLinks
links={[
{
title: t("environments.surveys.share.personal_links.work_with_segments"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting#segment-configuration",
},
]}
<div className="flex h-full grow flex-col gap-6">
{/* Recipients Section */}
<FormField
control={form.control}
name="selectedSegment"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common.recipients")}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={publicSegments.length === 0}>
<SelectTrigger className="w-full bg-white">
<SelectValue
placeholder={
publicSegments.length === 0
? t("environments.surveys.share.personal_links.no_segments_available")
: t("environments.surveys.share.personal_links.select_segment")
}
/>
</SelectTrigger>
<SelectContent>
{publicSegments.map((segment) => (
<SelectItem key={segment.id} value={segment.id}>
{segment.title}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
{t("environments.surveys.share.personal_links.create_and_manage_segments")}
</FormDescription>
</FormItem>
)}
/>
</TabContainer>
{/* Expiry Date Section */}
<FormField
control={form.control}
name="expiryDate"
render={({ field }) => (
<FormItem>
<FormLabel>{t("environments.surveys.share.personal_links.expiry_date_optional")}</FormLabel>
<FormControl>
<RestrictedDatePicker date={field.value} updateSurveyDate={field.onChange} />
</FormControl>
<FormDescription>
{t("environments.surveys.share.personal_links.expiry_date_description")}
</FormDescription>
</FormItem>
)}
/>
{/* Generate Button */}
<Button
onClick={handleGenerateLinks}
disabled={isButtonDisabled}
loading={isGenerating}
className="w-fit">
<DownloadIcon className="mr-2 h-4 w-4" />
{buttonText}
</Button>
</div>
<hr />
{/* Info Box */}
<DocumentationLinks
links={[
{
title: t("environments.surveys.share.personal_links.work_with_segments"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting#segment-configuration",
},
]}
/>
</FormProvider>
);
};

View File

@@ -157,30 +157,6 @@ describe("QRCodeTab", () => {
cleanup();
});
describe("Component rendering", () => {
test("renders component with title and description", () => {
render(<QRCodeTab surveyUrl={mockSurveyUrl} />);
expect(
screen.getByText("environments.surveys.summary.make_survey_accessible_via_qr_code")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.responses_collected_via_qr_code_are_anonymous")
).toBeInTheDocument();
});
test("renders without QR code when surveyUrl is empty", () => {
render(<QRCodeTab surveyUrl="" />);
expect(
screen.getByText("environments.surveys.summary.make_survey_accessible_via_qr_code")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.responses_collected_via_qr_code_are_anonymous")
).toBeInTheDocument();
});
});
describe("QR Code generation", () => {
test("attempts to generate QR code when surveyUrl is provided", async () => {
render(<QRCodeTab surveyUrl={mockSurveyUrl} />);
@@ -256,12 +232,6 @@ describe("QRCodeTab", () => {
test("shows appropriate state when surveyUrl is empty", async () => {
render(<QRCodeTab surveyUrl="" />);
// Component should render some content
await waitFor(() => {
const content = screen.getByText("environments.surveys.summary.make_survey_accessible_via_qr_code");
expect(content).toBeInTheDocument();
});
// Should show button (but disabled) when URL is empty, no alert
const button = screen.getByTestId("button");
expect(button).toBeInTheDocument();
@@ -287,20 +257,6 @@ describe("QRCodeTab", () => {
expect(screen.getByTestId("button")).toBeInTheDocument();
});
});
test("handles empty surveyUrl gracefully", async () => {
render(<QRCodeTab surveyUrl="" />);
// Component should render basic content even with empty URL
await waitFor(() => {
const title = screen.getByText("environments.surveys.summary.make_survey_accessible_via_qr_code");
const description = screen.getByText(
"environments.surveys.summary.responses_collected_via_qr_code_are_anonymous"
);
expect(title).toBeInTheDocument();
expect(description).toBeInTheDocument();
});
});
});
describe("Accessibility", () => {

View File

@@ -1,6 +1,5 @@
"use client";
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";
@@ -75,46 +74,42 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
};
return (
<div data-testid="qr-code-tab">
<TabContainer
title={t("environments.surveys.summary.make_survey_accessible_via_qr_code")}
description={t("environments.surveys.summary.responses_collected_via_qr_code_are_anonymous")}>
{isLoading && (
<div className="flex flex-col items-center gap-2">
<LoaderCircle className="h-8 w-8 animate-spin text-slate-500" />
<p className="text-sm text-slate-500">{t("environments.surveys.summary.generating_qr_code")}</p>
</div>
)}
<>
{isLoading && (
<div className="flex flex-col items-center gap-2">
<LoaderCircle className="h-8 w-8 animate-spin text-slate-500" />
<p className="text-sm text-slate-500">{t("environments.surveys.summary.generating_qr_code")}</p>
</div>
)}
{hasError && (
<Alert variant="error">
<AlertTitle>{t("common.something_went_wrong")}</AlertTitle>
<AlertDescription>{t("environments.surveys.summary.qr_code_generation_failed")}</AlertDescription>
</Alert>
)}
{hasError && (
<Alert variant="error">
<AlertTitle>{t("common.something_went_wrong")}</AlertTitle>
<AlertDescription>{t("environments.surveys.summary.qr_code_generation_failed")}</AlertDescription>
</Alert>
)}
{!isLoading && !hasError && (
<div className="flex flex-col items-start justify-center gap-4">
<div className="flex h-[184px] w-[184px] items-center justify-center overflow-hidden rounded-lg border bg-white">
<div ref={qrCodeRef} className="h-full w-full" />
</div>
<Button
onClick={downloadQRCode}
data-testid="download-qr-code-button"
disabled={!surveyUrl || isDownloading || hasError}
className="flex items-center gap-2">
{isDownloading
? t("environments.surveys.summary.downloading_qr_code")
: t("environments.surveys.summary.download_qr_code")}
{isDownloading ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
{!isLoading && !hasError && (
<div className="flex flex-col items-start justify-center gap-4">
<div className="flex h-[184px] w-[184px] items-center justify-center overflow-hidden rounded-lg border bg-white">
<div ref={qrCodeRef} className="h-full w-full" />
</div>
)}
</TabContainer>
</div>
<Button
onClick={downloadQRCode}
data-testid="download-qr-code-button"
disabled={!surveyUrl || isDownloading || hasError}
className="flex items-center gap-2">
{isDownloading
? t("environments.surveys.summary.downloading_qr_code")
: t("environments.surveys.summary.download_qr_code")}
{isDownloading ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
</div>
)}
</>
);
};

View File

@@ -1,11 +1,56 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { afterEach, beforeEach, 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 sidebar components
vi.mock("@/modules/ui/components/sidebar", () => ({
SidebarProvider: ({ children, open, className, style }: any) => (
<div data-testid="sidebar-provider" data-open={open} className={className} style={style}>
{children}
</div>
),
Sidebar: ({ children }: { children: React.ReactNode }) => <div data-testid="sidebar">{children}</div>,
SidebarContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="sidebar-content">{children}</div>
),
SidebarGroup: ({ children }: { children: React.ReactNode }) => (
<div data-testid="sidebar-group">{children}</div>
),
SidebarGroupContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="sidebar-group-content">{children}</div>
),
SidebarGroupLabel: ({ children }: { children: React.ReactNode }) => (
<div data-testid="sidebar-group-label">{children}</div>
),
SidebarMenu: ({ children }: { children: React.ReactNode }) => (
<div data-testid="sidebar-menu">{children}</div>
),
SidebarMenuItem: ({ children }: { children: React.ReactNode }) => (
<div data-testid="sidebar-menu-item">{children}</div>
),
SidebarMenuButton: ({
children,
onClick,
tooltip,
className,
isActive,
}: {
children: React.ReactNode;
onClick: () => void;
tooltip: string;
className?: string;
isActive?: boolean;
}) => (
<button type="button" onClick={onClick} className={className} aria-label={tooltip} data-active={isActive}>
{children}
</button>
),
}));
// Mock child components
vi.mock("./app-tab", () => ({
AppTab: () => <div data-testid="app-tab">AppTab Content</div>,
@@ -79,13 +124,6 @@ vi.mock("@/modules/ui/components/upgrade-prompt", () => ({
),
}));
// Mock @tolgee/react
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
// Mock lucide-react
vi.mock("lucide-react", () => ({
CopyIcon: () => <div data-testid="copy-icon">CopyIcon</div>,
@@ -118,33 +156,6 @@ vi.mock("lucide-react", () => ({
),
}));
// Mock sidebar components
vi.mock("@/modules/ui/components/sidebar", () => ({
SidebarProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Sidebar: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SidebarContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SidebarGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SidebarGroupContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SidebarGroupLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SidebarMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SidebarMenuItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SidebarMenuButton: ({
children,
onClick,
tooltip,
className,
}: {
children: React.ReactNode;
onClick: () => void;
tooltip: string;
className?: string;
}) => (
<button type="button" onClick={onClick} className={className} aria-label={tooltip}>
{children}
</button>
),
}));
// Mock tooltip and typography components
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipRenderer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
@@ -178,21 +189,69 @@ vi.mock("@/lib/cn", () => ({
cn: (...args: any[]) => args.filter(Boolean).join(" "),
}));
const mockTabs: Array<{ id: ShareViewType; label: string; icon: React.ElementType }> = [
{ id: ShareViewType.EMAIL, label: "Email", icon: () => <div data-testid="email-tab-icon" /> },
const mockTabs: Array<{
id: ShareViewType;
label: string;
icon: React.ElementType;
componentType: React.ComponentType<any>;
componentProps: any;
title: string;
description?: string;
}> = [
{
id: ShareViewType.EMAIL,
label: "Email",
icon: () => <div data-testid="email-tab-icon" />,
componentType: () => <div data-testid="email-tab-content">Email Content</div>,
componentProps: {},
title: "Email",
description: "Email Description",
},
{
id: ShareViewType.WEBSITE_EMBED,
label: "Website Embed",
icon: () => <div data-testid="website-embed-tab-icon" />,
componentType: () => <div data-testid="website-embed-tab-content">Website Embed Content</div>,
componentProps: {},
title: "Website Embed",
description: "Website Embed Description",
},
{
id: ShareViewType.DYNAMIC_POPUP,
label: "Dynamic Popup",
icon: () => <div data-testid="dynamic-popup-tab-icon" />,
componentType: () => <div data-testid="dynamic-popup-tab-content">Dynamic Popup Content</div>,
componentProps: {},
title: "Dynamic Popup",
description: "Dynamic Popup Description",
},
{
id: ShareViewType.ANON_LINKS,
label: "Anonymous Links",
icon: () => <div data-testid="link-tab-icon" />,
componentType: () => <div data-testid="anonymous-links-tab-content">Anonymous Links Content</div>,
componentProps: {},
title: "Anonymous Links",
description: "Anonymous Links Description",
},
{
id: ShareViewType.QR_CODE,
label: "QR Code",
icon: () => <div data-testid="qr-code-tab-icon" />,
componentType: () => <div data-testid="qr-code-tab-content">QR Code Content</div>,
componentProps: {},
title: "QR Code",
description: "QR Code Description",
},
{
id: ShareViewType.APP,
label: "App",
icon: () => <div data-testid="app-tab-icon" />,
componentType: () => <div data-testid="app-tab-content">App Content</div>,
componentProps: {},
title: "App",
description: "App Description",
},
{ id: ShareViewType.ANON_LINKS, label: "Anonymous Links", icon: () => <div data-testid="link-tab-icon" /> },
{ id: ShareViewType.QR_CODE, label: "QR Code", icon: () => <div data-testid="qr-code-tab-icon" /> },
{ id: ShareViewType.APP, label: "App", icon: () => <div data-testid="app-tab-icon" /> },
];
const mockSurveyLink = {
@@ -254,7 +313,20 @@ const defaultProps = {
isFormbricksCloud: false,
};
// Mock window object for resize testing
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 1024,
});
describe("ShareView", () => {
beforeEach(() => {
// Reset window size to default before each test
window.innerWidth = 1024;
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
@@ -285,58 +357,6 @@ describe("ShareView", () => {
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViewType.WEBSITE_EMBED);
});
test("renders EmailTab when activeId is 'email'", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
expect(screen.getByTestId("email-tab")).toBeInTheDocument();
expect(
screen.getByText(`EmailTab Content for ${defaultProps.survey.id} with ${defaultProps.email}`)
).toBeInTheDocument();
});
test("renders WebsiteEmbedTab when activeId is 'website-embed'", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.WEBSITE_EMBED} />);
expect(screen.getByTestId("website-embed-tab")).toBeInTheDocument();
expect(screen.getByText(`WebsiteEmbedTab Content for ${defaultProps.surveyUrl}`)).toBeInTheDocument();
});
test("renders DynamicPopupTab when activeId is 'dynamic-popup'", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.DYNAMIC_POPUP} />);
expect(screen.getByTestId("dynamic-popup-tab")).toBeInTheDocument();
expect(
screen.getByText(
`DynamicPopupTab Content for ${defaultProps.survey.id} in ${defaultProps.environmentId}`
)
).toBeInTheDocument();
});
test("renders AnonymousLinksTab when activeId is 'anon-links'", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.ANON_LINKS} />);
expect(screen.getByTestId("anonymous-links-tab")).toBeInTheDocument();
expect(
screen.getByText(`AnonymousLinksTab Content for ${defaultProps.survey.id} at ${defaultProps.surveyUrl}`)
).toBeInTheDocument();
});
test("renders QRCodeTab when activeId is 'qr-code'", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.QR_CODE} />);
expect(screen.getByTestId("qr-code-tab")).toBeInTheDocument();
});
test("renders AppTab when activeId is 'app'", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.APP} />);
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
});
test("renders PersonalLinksTab when activeId is 'personal-links'", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.PERSONAL_LINKS} />);
expect(screen.getByTestId("personal-links-tab")).toBeInTheDocument();
expect(
screen.getByText(
`PersonalLinksTab Content for ${defaultProps.survey.id} in ${defaultProps.environmentId}`
)
).toBeInTheDocument();
});
test("calls setActiveId when a responsive tab is clicked", async () => {
render(<ShareView {...defaultProps} survey={mockSurveyLink} activeId={ShareViewType.EMAIL} />);
@@ -400,4 +420,269 @@ describe("ShareView", () => {
);
}
});
describe("Responsive Behavior", () => {
test("detects large screen size on mount", () => {
window.innerWidth = 1200;
render(<ShareView {...defaultProps} survey={mockSurveyLink} />);
// SidebarProvider should be rendered with open=true for large screens
const sidebarProvider = screen.getByTestId("sidebar-provider");
expect(sidebarProvider).toHaveAttribute("data-open", "true");
});
test("detects small screen size on mount", () => {
window.innerWidth = 800;
render(<ShareView {...defaultProps} survey={mockSurveyLink} />);
// SidebarProvider should be rendered with open=false for small screens
const sidebarProvider = screen.getByTestId("sidebar-provider");
expect(sidebarProvider).toHaveAttribute("data-open", "false");
});
test("updates screen size on window resize", async () => {
window.innerWidth = 1200;
const { rerender } = render(<ShareView {...defaultProps} survey={mockSurveyLink} />);
// Initially large screen
let sidebarProvider = screen.getByTestId("sidebar-provider");
expect(sidebarProvider).toHaveAttribute("data-open", "true");
// Simulate window resize to small screen
window.innerWidth = 800;
window.dispatchEvent(new Event("resize"));
// Force re-render to trigger useEffect
rerender(<ShareView {...defaultProps} survey={mockSurveyLink} />);
// Should now be small screen
sidebarProvider = screen.getByTestId("sidebar-provider");
expect(sidebarProvider).toHaveAttribute("data-open", "false");
});
test("cleans up resize listener on unmount", () => {
const removeEventListenerSpy = vi.spyOn(window, "removeEventListener");
const { unmount } = render(<ShareView {...defaultProps} survey={mockSurveyLink} />);
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith("resize", expect.any(Function));
});
});
describe("TabContainer Integration", () => {
test("renders active tab with correct title and description", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
const tabContainer = screen.getByTestId("tab-container");
expect(tabContainer).toBeInTheDocument();
const tabTitle = screen.getByTestId("tab-title");
expect(tabTitle).toHaveTextContent("Email");
const tabDescription = screen.getByTestId("tab-description");
expect(tabDescription).toHaveTextContent("Email Description");
const tabContent = screen.getByTestId("email-tab-content");
expect(tabContent).toBeInTheDocument();
});
test("renders different tab when activeId changes", () => {
const { rerender } = render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
// Initially shows Email tab
expect(screen.getByTestId("tab-title")).toHaveTextContent("Email");
expect(screen.getByTestId("email-tab-content")).toBeInTheDocument();
// Change to Website Embed tab
rerender(<ShareView {...defaultProps} activeId={ShareViewType.WEBSITE_EMBED} />);
expect(screen.getByTestId("tab-title")).toHaveTextContent("Website Embed");
expect(screen.getByTestId("website-embed-tab-content")).toBeInTheDocument();
expect(screen.queryByTestId("email-tab-content")).not.toBeInTheDocument();
});
test("handles tab without description", () => {
const tabsWithoutDescription = [
{
id: ShareViewType.EMAIL,
label: "Email",
icon: () => <div data-testid="email-tab-icon" />,
componentType: () => <div data-testid="email-tab-content">Email Content</div>,
componentProps: {},
title: "Email",
// No description property
},
];
render(<ShareView {...defaultProps} tabs={tabsWithoutDescription} activeId={ShareViewType.EMAIL} />);
const tabDescription = screen.getByTestId("tab-description");
expect(tabDescription).toHaveTextContent("");
});
test("returns null when no active tab is found", () => {
const emptyTabs: typeof mockTabs = [];
render(<ShareView {...defaultProps} tabs={emptyTabs} activeId={ShareViewType.EMAIL} />);
const tabContainer = screen.queryByTestId("tab-container");
expect(tabContainer).not.toBeInTheDocument();
});
});
describe("SidebarProvider Configuration", () => {
test("renders SidebarProvider with correct props for link surveys", () => {
render(<ShareView {...defaultProps} survey={mockSurveyLink} />);
const sidebarProvider = screen.getByTestId("sidebar-provider");
expect(sidebarProvider).toBeInTheDocument();
expect(sidebarProvider).toHaveAttribute("data-open", "true");
expect(sidebarProvider).toHaveClass("flex min-h-0 w-auto lg:col-span-1");
expect(sidebarProvider).toHaveStyle("--sidebar-width: 100%");
});
test("does not render SidebarProvider for non-link surveys", () => {
render(<ShareView {...defaultProps} survey={mockSurveyWeb} />);
expect(screen.queryByTestId("sidebar-provider")).not.toBeInTheDocument();
});
test("renders correct grid layout for link surveys", () => {
render(<ShareView {...defaultProps} survey={mockSurveyLink} />);
const container = screen.getByTestId("sidebar-provider").parentElement;
expect(container).toHaveClass("lg:grid lg:grid-cols-4");
});
test("does not render grid layout for non-link surveys", () => {
const { container } = render(<ShareView {...defaultProps} survey={mockSurveyWeb} />);
const mainDiv = container.querySelector(".h-full > div");
expect(mainDiv).not.toHaveClass("lg:grid lg:grid-cols-4");
});
});
describe("Sidebar Menu Buttons", () => {
test("renders SidebarMenuButton with correct isActive prop", () => {
render(<ShareView {...defaultProps} survey={mockSurveyLink} activeId={ShareViewType.EMAIL} />);
const emailButton = screen.getByLabelText("Email");
expect(emailButton).toHaveAttribute("data-active", "true");
const websiteEmbedButton = screen.getByLabelText("Website Embed");
expect(websiteEmbedButton).toHaveAttribute("data-active", "false");
});
test("renders all tabs in sidebar menu", () => {
render(<ShareView {...defaultProps} survey={mockSurveyLink} />);
mockTabs.forEach((tab) => {
const button = screen.getByLabelText(tab.label);
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute("data-active", tab.id === ShareViewType.EMAIL ? "true" : "false");
});
});
});
describe("Mobile Responsive Buttons", () => {
test("renders mobile buttons for all tabs", () => {
render(<ShareView {...defaultProps} />);
// Mobile buttons should be present for all tabs
mockTabs.forEach((tab) => {
// Map ShareViewType to actual testid used in the component
const testIdMap: Record<string, string> = {
[ShareViewType.ANON_LINKS]: "link-tab-icon",
[ShareViewType.PERSONAL_LINKS]: "personal-links-tab-icon",
[ShareViewType.WEBSITE_EMBED]: "website-embed-tab-icon",
[ShareViewType.EMAIL]: "email-tab-icon",
[ShareViewType.SOCIAL_MEDIA]: "social-media-tab-icon",
[ShareViewType.QR_CODE]: "qr-code-tab-icon",
[ShareViewType.DYNAMIC_POPUP]: "dynamic-popup-tab-icon",
[ShareViewType.APP]: "app-tab-icon",
};
const expectedTestId = testIdMap[tab.id] || `${tab.id}-tab-icon`;
const mobileButtons = screen.getAllByTestId(expectedTestId);
const mobileButton = mobileButtons.find((icon) => {
const button = icon.closest("button");
return button && button.getAttribute("data-variant") === "ghost";
});
expect(mobileButton).toBeInTheDocument();
});
});
test("applies correct classes to mobile buttons based on active state", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.WEBSITE_EMBED} />);
const websiteEmbedIcons = screen.getAllByTestId("website-embed-tab-icon");
const activeMobileButton = websiteEmbedIcons
.find((icon) => {
const button = icon.closest("button");
return button && button.getAttribute("data-variant") === "ghost";
})
?.closest("button");
if (activeMobileButton) {
expect(activeMobileButton).toHaveClass("bg-white text-slate-900 shadow-sm hover:bg-white");
}
});
});
describe("Content Area Layout", () => {
test("applies correct column span for link surveys", () => {
const { container } = render(<ShareView {...defaultProps} survey={mockSurveyLink} />);
const contentArea = container.querySelector('[class*="lg:col-span-3"]');
expect(contentArea).toBeInTheDocument();
expect(contentArea).toHaveClass("lg:col-span-3");
});
test("does not apply column span for non-link surveys", () => {
const { container } = render(<ShareView {...defaultProps} survey={mockSurveyWeb} />);
const contentArea = container.querySelector('[class*="lg:col-span-3"]');
expect(contentArea).toBeNull();
});
test("renders mobile button container with correct visibility class", () => {
const { container } = render(<ShareView {...defaultProps} />);
const mobileButtonContainer = container.querySelector(".md\\:hidden");
expect(mobileButtonContainer).toBeInTheDocument();
expect(mobileButtonContainer).toHaveClass("md:hidden");
});
});
describe("Enhanced Tab Structure", () => {
test("handles tabs with all required properties", () => {
const completeTab = {
id: ShareViewType.EMAIL,
label: "Test Email",
icon: () => <div data-testid="test-icon" />,
componentType: () => <div data-testid="test-content">Test Content</div>,
componentProps: {},
title: "Test Title",
description: "Test Description",
};
render(<ShareView {...defaultProps} tabs={[completeTab]} activeId={ShareViewType.EMAIL} />);
expect(screen.getByTestId("tab-title")).toHaveTextContent("Test Title");
expect(screen.getByTestId("tab-description")).toHaveTextContent("Test Description");
expect(screen.getByTestId("test-content")).toBeInTheDocument();
});
test("uses title from tab definition in TabContainer", () => {
const customTitleTab = {
...mockTabs[0],
title: "Custom Email Title",
};
render(<ShareView {...defaultProps} tabs={[customTitleTab]} activeId={ShareViewType.EMAIL} />);
expect(screen.getByTestId("tab-title")).toHaveTextContent("Custom Email Title");
});
});
});

View File

@@ -1,7 +1,6 @@
"use client";
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 { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
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";
@@ -20,46 +19,24 @@ import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { Small } from "@/modules/ui/components/typography";
import { useTranslate } from "@tolgee/react";
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 { 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: ShareViewType; label: string; icon: React.ElementType }>;
tabs: Array<{
id: ShareViewType;
label: string;
icon: React.ElementType;
componentType: React.ComponentType<any>;
componentProps: any;
title: string;
description?: string;
}>;
activeId: ShareViewType;
setActiveId: React.Dispatch<React.SetStateAction<ShareViewType>>;
environmentId: string;
survey: TSurvey;
email: string;
surveyUrl: string;
publicDomain: string;
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
locale: TUserLocale;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
}
export const ShareView = ({
tabs,
activeId,
setActiveId,
environmentId,
survey,
email,
surveyUrl,
publicDomain,
setSurveyUrl,
locale,
segments,
isContactsEnabled,
isFormbricksCloud,
}: ShareViewProps) => {
export const ShareView = ({ tabs, activeId, setActiveId, survey }: ShareViewProps) => {
const { t } = useTranslate();
const [isLargeScreen, setIsLargeScreen] = useState(true);
@@ -76,40 +53,16 @@ export const ShareView = ({
}, []);
const renderActiveTab = () => {
switch (activeId) {
case ShareViewType.EMAIL:
return <EmailTab surveyId={survey.id} email={email} />;
case ShareViewType.WEBSITE_EMBED:
return <WebsiteEmbedTab surveyUrl={surveyUrl} />;
case ShareViewType.DYNAMIC_POPUP:
return <DynamicPopupTab environmentId={environmentId} surveyId={survey.id} />;
case ShareViewType.ANON_LINKS:
return (
<AnonymousLinksTab
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
);
case ShareViewType.APP:
return <AppTab />;
case ShareViewType.QR_CODE:
return <QRCodeTab surveyUrl={surveyUrl} />;
case ShareViewType.PERSONAL_LINKS:
return (
<PersonalLinksTab
segments={segments}
surveyId={survey.id}
environmentId={environmentId}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/>
);
default:
return null;
}
const activeTab = tabs.find((tab) => tab.id === activeId);
if (!activeTab) return null;
const { componentType: Component, componentProps } = activeTab;
return (
<TabContainer key={activeTab.id} title={activeTab.title} description={activeTab.description ?? ""}>
<Component {...componentProps} />
</TabContainer>
);
};
return (

View File

@@ -0,0 +1,138 @@
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 { SocialMediaTab } from "./social-media-tab";
// Mock next/link
vi.mock("next/link", () => ({
default: ({ href, children, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
),
}));
// Mock window.open
Object.defineProperty(window, "open", {
writable: true,
value: vi.fn(),
});
const mockSurveyUrl = "https://app.formbricks.com/s/survey1";
const mockSurveyTitle = "Test Survey";
const expectedPlatforms = [
{ name: "LinkedIn", description: "Share on LinkedIn" },
{ name: "Threads", description: "Share on Threads" },
{ name: "Facebook", description: "Share on Facebook" },
{ name: "Reddit", description: "Share on Reddit" },
{ name: "X", description: "Share on X (formerly Twitter)" },
];
describe("SocialMediaTab", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders all social media platforms with correct names", () => {
render(<SocialMediaTab surveyUrl={mockSurveyUrl} surveyTitle={mockSurveyTitle} />);
expectedPlatforms.forEach((platform) => {
expect(screen.getByText(platform.name)).toBeInTheDocument();
});
});
test("renders source tracking alert with correct content", () => {
render(<SocialMediaTab surveyUrl={mockSurveyUrl} surveyTitle={mockSurveyTitle} />);
expect(
screen.getByText("environments.surveys.share.social_media.source_tracking_enabled")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.social_media.source_tracking_enabled_alert_description")
).toBeInTheDocument();
expect(screen.getByText("common.learn_more")).toBeInTheDocument();
const learnMoreButton = screen.getByRole("button", { name: "common.learn_more" });
expect(learnMoreButton).toBeInTheDocument();
});
test("renders platform buttons for all platforms", () => {
render(<SocialMediaTab surveyUrl={mockSurveyUrl} surveyTitle={mockSurveyTitle} />);
const platformButtons = expectedPlatforms.map((platform) =>
screen.getByRole("button", { name: new RegExp(platform.name, "i") })
);
expect(platformButtons).toHaveLength(expectedPlatforms.length);
});
test("opens sharing window when LinkedIn button is clicked", async () => {
const mockWindowOpen = vi.spyOn(window, "open");
render(<SocialMediaTab surveyUrl={mockSurveyUrl} surveyTitle={mockSurveyTitle} />);
const linkedInButton = screen.getByRole("button", { name: /linkedin/i });
await userEvent.click(linkedInButton);
expect(mockWindowOpen).toHaveBeenCalledWith(
expect.stringContaining("linkedin.com/shareArticle"),
"share-dialog",
"width=1024,height=768,location=no,toolbar=no,status=no,menubar=no,scrollbars=yes,resizable=yes,noopener=yes,noreferrer=yes"
);
});
test("includes source tracking in shared URLs", async () => {
const mockWindowOpen = vi.spyOn(window, "open");
render(<SocialMediaTab surveyUrl={mockSurveyUrl} surveyTitle={mockSurveyTitle} />);
const linkedInButton = screen.getByRole("button", { name: /linkedin/i });
await userEvent.click(linkedInButton);
const calledUrl = mockWindowOpen.mock.calls[0][0] as string;
const decodedUrl = decodeURIComponent(calledUrl);
expect(decodedUrl).toContain("source=linkedin");
});
test("opens sharing window when Facebook button is clicked", async () => {
const mockWindowOpen = vi.spyOn(window, "open");
render(<SocialMediaTab surveyUrl={mockSurveyUrl} surveyTitle={mockSurveyTitle} />);
const facebookButton = screen.getByRole("button", { name: /facebook/i });
await userEvent.click(facebookButton);
expect(mockWindowOpen).toHaveBeenCalledWith(
expect.stringContaining("facebook.com/sharer"),
"share-dialog",
"width=1024,height=768,location=no,toolbar=no,status=no,menubar=no,scrollbars=yes,resizable=yes,noopener=yes,noreferrer=yes"
);
});
test("opens sharing window when X button is clicked", async () => {
const mockWindowOpen = vi.spyOn(window, "open");
render(<SocialMediaTab surveyUrl={mockSurveyUrl} surveyTitle={mockSurveyTitle} />);
const xButton = screen.getByRole("button", { name: /^x$/i });
await userEvent.click(xButton);
expect(mockWindowOpen).toHaveBeenCalledWith(
expect.stringContaining("twitter.com/intent/tweet"),
"share-dialog",
"width=1024,height=768,location=no,toolbar=no,status=no,menubar=no,scrollbars=yes,resizable=yes,noopener=yes,noreferrer=yes"
);
});
test("encodes URLs and titles correctly for sharing", async () => {
const specialCharUrl = "https://app.formbricks.com/s/survey1?param=test&other=value";
const specialCharTitle = "Test Survey & More";
const mockWindowOpen = vi.spyOn(window, "open");
render(<SocialMediaTab surveyUrl={specialCharUrl} surveyTitle={specialCharTitle} />);
const linkedInButton = screen.getByRole("button", { name: /linkedin/i });
await userEvent.click(linkedInButton);
const calledUrl = mockWindowOpen.mock.calls[0][0] as string;
expect(calledUrl).toContain(encodeURIComponent(specialCharTitle));
});
});

View File

@@ -0,0 +1,114 @@
"use client";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { FacebookIcon } from "@/modules/ui/components/icons/facebook-icon";
import { LinkedinIcon } from "@/modules/ui/components/icons/linkedin-icon";
import { RedditIcon } from "@/modules/ui/components/icons/reddit-icon";
import { ThreadsIcon } from "@/modules/ui/components/icons/threads-icon";
import { XIcon } from "@/modules/ui/components/icons/x-icon";
import { useTranslate } from "@tolgee/react";
import { AlertCircleIcon } from "lucide-react";
import { useMemo } from "react";
interface SocialMediaTabProps {
surveyUrl: string;
surveyTitle: string;
}
export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({ surveyUrl, surveyTitle }) => {
const { t } = useTranslate();
const socialMediaPlatforms = useMemo(() => {
const shareText = surveyTitle;
// Add source tracking to the survey URL
const getTrackedUrl = (platform: string) => {
const sourceParam = `source=${platform.toLowerCase()}`;
const separator = surveyUrl.includes("?") ? "&" : "?";
return `${surveyUrl}${separator}${sourceParam}`;
};
return [
{
id: "linkedin",
name: "LinkedIn",
icon: <LinkedinIcon />,
url: `https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(getTrackedUrl("linkedin"))}&title=${encodeURIComponent(shareText)}`,
description: "Share on LinkedIn",
},
{
id: "threads",
name: "Threads",
icon: <ThreadsIcon />,
url: `https://www.threads.net/intent/post?text=${encodeURIComponent(shareText)}%20${encodeURIComponent(getTrackedUrl("threads"))}`,
description: "Share on Threads",
},
{
id: "facebook",
name: "Facebook",
icon: <FacebookIcon />,
url: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(getTrackedUrl("facebook"))}`,
description: "Share on Facebook",
},
{
id: "reddit",
name: "Reddit",
icon: <RedditIcon />,
url: `https://www.reddit.com/submit?url=${encodeURIComponent(getTrackedUrl("reddit"))}&title=${encodeURIComponent(shareText)}`,
description: "Share on Reddit",
},
{
id: "x",
name: "X",
icon: <XIcon />,
url: `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(getTrackedUrl("x"))}`,
description: "Share on X (formerly Twitter)",
},
];
}, [surveyUrl, surveyTitle]);
const handleSocialShare = (url: string) => {
// Open sharing window
window.open(
url,
"share-dialog",
"width=1024,height=768,location=no,toolbar=no,status=no,menubar=no,scrollbars=yes,resizable=yes,noopener=yes,noreferrer=yes"
);
};
return (
<>
<div className="grid grid-cols-1 gap-4">
{socialMediaPlatforms.map((platform) => (
<Button
key={platform.name}
variant="outline"
className="w-fit bg-white"
onClick={() => handleSocialShare(platform.url)}>
{platform.name}
{platform.icon}
</Button>
))}
</div>
<Alert>
<AlertCircleIcon />
<AlertTitle>{t("environments.surveys.share.social_media.source_tracking_enabled")}</AlertTitle>
<AlertDescription>
{t("environments.surveys.share.social_media.source_tracking_enabled_alert_description")}
</AlertDescription>
<AlertButton
onClick={() => {
window.open(
"https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/source-tracking",
"_blank",
"noopener,noreferrer"
);
}}>
{t("common.learn_more")}
</AlertButton>
</Alert>
</>
);
};

View File

@@ -7,7 +7,6 @@ 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;
@@ -25,9 +24,7 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
</div>`;
return (
<TabContainer
title={t("environments.surveys.share.embed_on_website.title")}
description={t("environments.surveys.share.embed_on_website.description")}>
<>
<CodeBlock language="html" noMargin>
{iframeCode}
</CodeBlock>
@@ -50,6 +47,6 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
{t("common.copy_code")}
<CopyIcon />
</Button>
</TabContainer>
</>
);
};

View File

@@ -6,5 +6,6 @@ export enum ShareViewType {
APP = "app",
WEBSITE_EMBED = "website-embed",
DYNAMIC_POPUP = "dynamic-popup",
SOCIAL_MEDIA = "social-media",
QR_CODE = "qr-code",
}

View File

@@ -9,6 +9,11 @@ vi.mock("@/lib/utils/recall", () => ({
vi.mock("./i18n/utils", () => ({
getLocalizedValue: vi.fn((obj, lang) => obj[lang] || obj.default),
getLanguageCode: vi.fn((surveyLanguages, languageCode) => {
if (!surveyLanguages?.length || !languageCode) return null; // Changed from "default" to null
const language = surveyLanguages.find((surveyLanguage) => surveyLanguage.language.code === languageCode);
return language?.default ? "default" : language?.language.code || "default";
}),
}));
describe("Response Processing", () => {
@@ -43,6 +48,16 @@ describe("Response Processing", () => {
test("should return empty string for unsupported types", () => {
expect(processResponseData(undefined as any)).toBe("");
});
test("should filter out null values from array", () => {
const input = ["a", null, "c"] as any;
expect(processResponseData(input)).toBe("a; c");
});
test("should filter out undefined values from array", () => {
const input = ["a", undefined, "c"] as any;
expect(processResponseData(input)).toBe("a; c");
});
});
describe("convertResponseValue", () => {
@@ -125,6 +140,22 @@ describe("Response Processing", () => {
expect(convertResponseValue("invalid", mockPictureSelectionQuestion)).toEqual([]);
});
test("should handle pictureSelection type with number input", () => {
expect(convertResponseValue(42, mockPictureSelectionQuestion)).toEqual([]);
});
test("should handle pictureSelection type with object input", () => {
expect(convertResponseValue({ key: "value" }, mockPictureSelectionQuestion)).toEqual([]);
});
test("should handle pictureSelection type with null input", () => {
expect(convertResponseValue(null as any, mockPictureSelectionQuestion)).toEqual([]);
});
test("should handle pictureSelection type with undefined input", () => {
expect(convertResponseValue(undefined as any, mockPictureSelectionQuestion)).toEqual([]);
});
test("should handle default case with string input", () => {
expect(convertResponseValue("answer", mockOpenTextQuestion)).toBe("answer");
});
@@ -320,6 +351,32 @@ describe("Response Processing", () => {
charLimit: { enabled: false },
},
],
languages: [
{
language: {
id: "lang1",
code: "default",
createdAt: new Date(),
updatedAt: new Date(),
alias: null,
projectId: "proj1",
},
default: true,
enabled: true,
},
{
language: {
id: "lang2",
code: "en",
createdAt: new Date(),
updatedAt: new Date(),
alias: null,
projectId: "proj1",
},
default: false,
enabled: true,
},
],
};
const response = {
id: "response1",
@@ -349,5 +406,102 @@ describe("Response Processing", () => {
const mapping = getQuestionResponseMapping(survey, response);
expect(mapping[0].question).toBe("Question 1 EN");
});
test("should handle null response language", () => {
const response = {
id: "response1",
surveyId: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
finished: true,
data: { q1: "Answer 1" },
language: null,
meta: {
url: undefined,
country: undefined,
action: undefined,
source: undefined,
userAgent: undefined,
},
notes: [],
tags: [],
person: null,
personAttributes: {},
ttc: {},
variables: {},
contact: null,
contactAttributes: {},
singleUseId: null,
};
const mapping = getQuestionResponseMapping(mockSurvey, response);
expect(mapping).toHaveLength(2);
expect(mapping[0].question).toBe("Question 1");
});
test("should handle undefined response language", () => {
const response = {
id: "response1",
surveyId: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
finished: true,
data: { q1: "Answer 1" },
language: null,
meta: {
url: undefined,
country: undefined,
action: undefined,
source: undefined,
userAgent: undefined,
},
notes: [],
tags: [],
person: null,
personAttributes: {},
ttc: {},
variables: {},
contact: null,
contactAttributes: {},
singleUseId: null,
};
const mapping = getQuestionResponseMapping(mockSurvey, response);
expect(mapping).toHaveLength(2);
expect(mapping[0].question).toBe("Question 1");
});
test("should handle empty survey languages", () => {
const survey = {
...mockSurvey,
languages: [], // Empty languages array
};
const response = {
id: "response1",
surveyId: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
finished: true,
data: { q1: "Answer 1" },
language: "en",
meta: {
url: undefined,
country: undefined,
action: undefined,
source: undefined,
userAgent: undefined,
},
notes: [],
tags: [],
person: null,
personAttributes: {},
ttc: {},
variables: {},
contact: null,
contactAttributes: {},
singleUseId: null,
};
const mapping = getQuestionResponseMapping(survey, response);
expect(mapping).toHaveLength(2);
expect(mapping[0].question).toBe("Question 1"); // Should fallback to default
});
});
});

View File

@@ -1,7 +1,7 @@
import { parseRecallInfo } from "@/lib/utils/recall";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "./i18n/utils";
import { getLanguageCode, getLocalizedValue } from "./i18n/utils";
// function to convert response value of type string | number | string[] or Record<string, string> to string | string[]
export const convertResponseValue = (
@@ -39,12 +39,14 @@ export const getQuestionResponseMapping = (
response: string | string[];
type: TSurveyQuestionType;
}[] = [];
const responseLanguageCode = getLanguageCode(survey.languages, response.language);
for (const question of survey.questions) {
const answer = response.data[question.id];
questionResponseMapping.push({
question: parseRecallInfo(
getLocalizedValue(question.headline, response.language ?? "default"),
getLocalizedValue(question.headline, responseLanguageCode ?? "default"),
response.data
),
response: convertResponseValue(answer, question),

View File

@@ -1799,7 +1799,14 @@
"send_preview_email": "Vorschau-E-Mail senden",
"title": "Binden Sie Ihre Umfrage in eine E-Mail ein"
},
"share_view_title": "Teilen über"
"share_view_title": "Teilen über",
"social_media": {
"description": "Erhalte Rückmeldungen von deinen Kontakten auf verschiedenen sozialen Medien.",
"share_your_survey_on_social_media": "Teilen Sie Ihre Umfrage in sozialen Medien",
"source_tracking_enabled": "Quellenverfolgung aktiviert",
"source_tracking_enabled_alert_description": "Wenn Sie aus diesem Dialogfenster teilen, wird das soziale Netzwerk an den Umfragelink angehängt, sodass Sie wissen, welche Antworten über welches Netzwerk eingegangen sind.",
"title": "Soziale Medien"
}
},
"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",
@@ -1849,6 +1856,7 @@
"publish_to_web_warning": "Du bist dabei, diese Umfrageergebnisse öffentlich zugänglich zu machen.",
"publish_to_web_warning_description": "Deine Umfrageergebnisse werden öffentlich sein. Jeder außerhalb deiner Organisation kann darauf zugreifen, wenn er den Link hat.",
"qr_code": "QR-Code",
"qr_code_description": "Antworten, die per QR-Code gesammelt werden, sind anonym.",
"qr_code_download_failed": "QR-Code-Download fehlgeschlagen",
"qr_code_download_with_start_soon": "QR Code-Download startet bald",
"qr_code_generation_failed": "Es gab ein Problem beim Laden des QR-Codes für die Umfrage. Bitte versuchen Sie es erneut.",

View File

@@ -1799,7 +1799,14 @@
"send_preview_email": "Send preview email",
"title": "Embed your survey in an email"
},
"share_view_title": "Share via"
"share_view_title": "Share via",
"social_media": {
"description": "Get responses from your contacts on various social media networks.",
"share_your_survey_on_social_media": "Share your survey on social media",
"source_tracking_enabled": "Source tracking enabled",
"source_tracking_enabled_alert_description": "When sharing from this dialog, the social media network will be appended to the survey link so you know which responses came via each network.",
"title": "Social media"
}
},
"summary": {
"added_filter_for_responses_where_answer_to_question": "Added filter for responses where answer to question {questionIdx} is {filterComboBoxValue} - {filterValue} ",
@@ -1849,6 +1856,7 @@
"publish_to_web_warning": "You are about to release these survey results to the public.",
"publish_to_web_warning_description": "Your survey results will be public. Anyone outside your organization can access them if they have the link.",
"qr_code": "QR code",
"qr_code_description": "Responses collected via QR code are anonymous.",
"qr_code_download_failed": "QR code download failed",
"qr_code_download_with_start_soon": "QR code download will start soon",
"qr_code_generation_failed": "There was a problem, loading the survey QR Code. Please try again.",

View File

@@ -1799,7 +1799,14 @@
"send_preview_email": "Envoyer un e-mail d'aperçu",
"title": "Intégrez votre sondage dans un e-mail"
},
"share_view_title": "Partager par"
"share_view_title": "Partager par",
"social_media": {
"description": "Obtenez des réponses de vos contacts sur divers réseaux sociaux.",
"share_your_survey_on_social_media": "Partagez votre sondage sur les réseaux sociaux",
"source_tracking_enabled": "Suivi des sources activé",
"source_tracking_enabled_alert_description": "En partageant depuis cette boîte de dialogue, le réseau social sera ajouté au lien du sondage afin que vous sachiez quelles réponses proviennent de chaque réseau.",
"title": "Médias sociaux"
}
},
"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'}' ",
@@ -1849,6 +1856,7 @@
"publish_to_web_warning": "Vous êtes sur le point de rendre ces résultats d'enquête publics.",
"publish_to_web_warning_description": "Les résultats de votre enquête seront publics. Toute personne en dehors de votre organisation pourra y accéder si elle a le lien.",
"qr_code": "Code QR",
"qr_code_description": "Les réponses collectées via le code QR sont anonymes.",
"qr_code_download_failed": "Échec du téléchargement du code QR",
"qr_code_download_with_start_soon": "Le téléchargement du code QR débutera bientôt",
"qr_code_generation_failed": "\"Un problème est survenu lors du chargement du code QR du sondage. Veuillez réessayer.\"",

View File

@@ -1799,7 +1799,14 @@
"send_preview_email": "Enviar prévia de e-mail",
"title": "Incorpore sua pesquisa em um e-mail"
},
"share_view_title": "Compartilhar via"
"share_view_title": "Compartilhar via",
"social_media": {
"description": "Obtenha respostas de seus contatos em várias redes sociais.",
"share_your_survey_on_social_media": "Compartilhe sua pesquisa nas redes sociais",
"source_tracking_enabled": "rastreamento de origem ativado",
"source_tracking_enabled_alert_description": "Ao compartilhar a partir deste diálogo, a rede social será adicionada ao link da pesquisa para que você saiba de qual rede vieram as respostas.",
"title": "Mídia Social"
}
},
"summary": {
"added_filter_for_responses_where_answer_to_question": "Adicionado filtro para respostas onde a resposta à pergunta {questionIdx} é {filterComboBoxValue} - {filterValue} ",
@@ -1849,6 +1856,7 @@
"publish_to_web_warning": "Você está prestes a divulgar esses resultados da pesquisa para o público.",
"publish_to_web_warning_description": "Os resultados da sua pesquisa serão públicos. Qualquer pessoa fora da sua organização pode acessá-los se tiver o link.",
"qr_code": "Código QR",
"qr_code_description": "Respostas coletadas via código QR são anônimas.",
"qr_code_download_failed": "falha no download do código QR",
"qr_code_download_with_start_soon": "O download do código QR começará em breve",
"qr_code_generation_failed": "Houve um problema ao carregar o Código QR do questionário. Por favor, tente novamente.",

View File

@@ -1799,7 +1799,14 @@
"send_preview_email": "Enviar pré-visualização de email",
"title": "Incorporar o seu inquérito num email"
},
"share_view_title": "Partilhar via"
"share_view_title": "Partilhar via",
"social_media": {
"description": "Obtenha respostas dos seus contactos em várias redes sociais.",
"share_your_survey_on_social_media": "Partilhe o seu inquérito nas redes sociais",
"source_tracking_enabled": "Rastreamento de origem ativado",
"source_tracking_enabled_alert_description": "Ao partilhar a partir deste diálogo, a rede social será anexada ao link do inquérito para que saiba de que rede vieram as respostas.",
"title": "Redes Sociais"
}
},
"summary": {
"added_filter_for_responses_where_answer_to_question": "Adicionado filtro para respostas onde a resposta à pergunta {questionIdx} é {filterComboBoxValue} - {filterValue} ",
@@ -1849,6 +1856,7 @@
"publish_to_web_warning": "Está prestes a divulgar estes resultados do inquérito ao público.",
"publish_to_web_warning_description": "Os resultados do seu inquérito serão públicos. Qualquer pessoa fora da sua organização pode aceder a eles se tiver o link.",
"qr_code": "Código QR",
"qr_code_description": "Respostas recolhidas através de código QR são anónimas.",
"qr_code_download_failed": "Falha ao transferir o código QR",
"qr_code_download_with_start_soon": "O download do código QR começará em breve",
"qr_code_generation_failed": "Ocorreu um problema ao carregar o Código QR do questionário. Por favor, tente novamente.",

View File

@@ -1799,7 +1799,14 @@
"send_preview_email": "發送預覽電子郵件",
"title": "嵌入 你的 調查 在 電子郵件 中"
},
"share_view_title": "透過 分享"
"share_view_title": "透過 分享",
"social_media": {
"description": "從 您 的 聯絡人 在 各 種 社交 媒體 網絡 上 獲得 回應。",
"share_your_survey_on_social_media": "分享 您 的 問卷 在 社交媒體 上",
"source_tracking_enabled": "來源追蹤已啟用",
"source_tracking_enabled_alert_description": "從 此 對 話 框 共 享 時,社 交 媒 體 網 絡 會 被 附 加 到 調 查鏈 接 下,讓 您 知 道 各 網 絡 的 回 應 來 源。",
"title": "社群媒體"
}
},
"summary": {
"added_filter_for_responses_where_answer_to_question": "已新增回應的篩選器,其中問題 '{'questionIdx'}' 的答案為 '{'filterComboBoxValue'}' - '{'filterValue'}'",
@@ -1849,6 +1856,7 @@
"publish_to_web_warning": "您即將將這些問卷結果發布到公共領域。",
"publish_to_web_warning_description": "您的問卷結果將會是公開的。任何組織外的人員都可以存取這些結果(如果他們有連結)。",
"qr_code": "QR 碼",
"qr_code_description": "透過 QR code 收集的回應都是匿名的。",
"qr_code_download_failed": "QR code 下載失敗",
"qr_code_download_with_start_soon": "QR code 下載即將開始",
"qr_code_generation_failed": "載入調查 QR Code 時發生問題。請再試一次。",

View File

@@ -8,7 +8,7 @@ interface EmailButtonProps {
export function EmailButton({ label, href }: EmailButtonProps): React.JSX.Element {
return (
<Button className="rounded-md bg-black px-6 py-3 text-white" href={href}>
<Button className="rounded-md bg-black px-6 py-3 text-sm text-white" href={href}>
{label}
</Button>
);

View File

@@ -4,7 +4,7 @@ import React from "react";
export function EmailFooter({ t }: { t: TFnType }): React.JSX.Element {
return (
<Text>
<Text className="text-sm">
{t("emails.email_footer_text_1")}
<br />
{t("emails.email_footer_text_2")}

View File

@@ -23,7 +23,7 @@ export async function EmailTemplate({
<Html>
<Tailwind>
<Body
className="m-0 h-full w-full justify-center bg-slate-50 p-6 text-center text-base font-medium text-slate-800"
className="m-0 h-full w-full justify-center bg-slate-50 p-6 text-center text-sm text-slate-800"
style={{
fontFamily: "'Jost', 'Helvetica Neue', 'Segoe UI', 'Helvetica', 'sans-serif'",
}}>
@@ -47,24 +47,32 @@ export async function EmailTemplate({
<Section className="mt-4 text-center text-sm">
<Link
className="m-0 font-normal text-slate-500"
className="m-0 text-sm font-normal text-slate-500"
href="https://formbricks.com/?utm_source=email_header&utm_medium=email"
target="_blank"
rel="noopener noreferrer">
{t("emails.email_template_text_1")}
</Link>
{IMPRINT_ADDRESS && (
<Text className="m-0 font-normal text-slate-500 opacity-50">{IMPRINT_ADDRESS}</Text>
<Text className="m-0 text-sm font-normal text-slate-500 opacity-50">{IMPRINT_ADDRESS}</Text>
)}
<Text className="m-0 font-normal text-slate-500 opacity-50">
<Text className="m-0 text-sm font-normal text-slate-500 opacity-50">
{IMPRINT_URL && (
<Link href={IMPRINT_URL} target="_blank" rel="noopener noreferrer" className="text-slate-500">
<Link
href={IMPRINT_URL}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-slate-500">
{t("emails.imprint")}
</Link>
)}
{IMPRINT_URL && PRIVACY_URL && " • "}
{PRIVACY_URL && (
<Link href={PRIVACY_URL} target="_blank" rel="noopener noreferrer" className="text-slate-500">
<Link
href={PRIVACY_URL}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-slate-500">
{t("emails.privacy_policy")}
</Link>
)}

View File

@@ -17,10 +17,10 @@ export async function ForgotPasswordEmail({
<EmailTemplate t={t}>
<Container>
<Heading>{t("emails.forgot_password_email_heading")}</Heading>
<Text>{t("emails.forgot_password_email_text")}</Text>
<Text className="text-sm">{t("emails.forgot_password_email_text")}</Text>
<EmailButton href={verifyLink} label={t("emails.forgot_password_email_change_password")} />
<Text className="font-bold">{t("emails.forgot_password_email_link_valid_for_24_hours")}</Text>
<Text className="mb-0">{t("emails.forgot_password_email_did_not_request")}</Text>
<Text className="text-sm font-bold">{t("emails.forgot_password_email_link_valid_for_24_hours")}</Text>
<Text className="mb-0 text-sm">{t("emails.forgot_password_email_did_not_request")}</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>

View File

@@ -17,14 +17,14 @@ export async function NewEmailVerification({
<EmailTemplate t={t}>
<Container>
<Heading>{t("emails.verification_email_heading")}</Heading>
<Text>{t("emails.new_email_verification_text")}</Text>
<Text>{t("emails.verification_security_notice")}</Text>
<Text className="text-sm">{t("emails.new_email_verification_text")}</Text>
<Text className="text-sm">{t("emails.verification_security_notice")}</Text>
<EmailButton href={verifyLink} label={t("emails.verification_email_verify_email")} />
<Text>{t("emails.verification_email_click_on_this_link")}</Text>
<Link className="break-all text-black" href={verifyLink}>
<Text className="text-sm">{t("emails.verification_email_click_on_this_link")}</Text>
<Link className="break-all text-sm text-black" href={verifyLink}>
{verifyLink}
</Link>
<Text className="font-bold">{t("emails.verification_email_link_valid_for_24_hours")}</Text>
<Text className="text-sm font-bold">{t("emails.verification_email_link_valid_for_24_hours")}</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>

View File

@@ -10,7 +10,7 @@ export async function PasswordResetNotifyEmail(): Promise<React.JSX.Element> {
<EmailTemplate t={t}>
<Container>
<Heading>{t("emails.password_changed_email_heading")}</Heading>
<Text>{t("emails.password_changed_email_text")}</Text>
<Text className="text-sm">{t("emails.password_changed_email_text")}</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>

View File

@@ -19,16 +19,16 @@ export async function VerificationEmail({
<EmailTemplate t={t}>
<Container>
<Heading>{t("emails.verification_email_heading")}</Heading>
<Text>{t("emails.verification_email_text")}</Text>
<Text className="text-sm">{t("emails.verification_email_text")}</Text>
<EmailButton href={verifyLink} label={t("emails.verification_email_verify_email")} />
<Text>{t("emails.verification_email_click_on_this_link")}</Text>
<Link className="break-all text-black" href={verifyLink}>
<Text className="text-sm">{t("emails.verification_email_click_on_this_link")}</Text>
<Link className="break-all text-sm text-black" href={verifyLink}>
{verifyLink}
</Link>
<Text className="font-bold">{t("emails.verification_email_link_valid_for_24_hours")}</Text>
<Text>
<Text className="text-sm font-bold">{t("emails.verification_email_link_valid_for_24_hours")}</Text>
<Text className="text-sm">
{t("emails.verification_email_if_expired_request_new_token")}
<Link className="text-black underline" href={verificationRequestLink}>
<Link className="text-sm text-black underline" href={verificationRequestLink}>
{t("emails.verification_email_request_new_verification")}
</Link>
</Text>

View File

@@ -16,10 +16,8 @@ export async function EmailCustomizationPreviewEmail({
return (
<EmailTemplate logoUrl={logoUrl} t={t}>
<Container>
<Heading className="text-xl">
{t("emails.email_customization_preview_email_heading", { userName })}
</Heading>
<Text className="font-normal">{t("emails.email_customization_preview_email_text")}</Text>
<Heading>{t("emails.email_customization_preview_email_heading", { userName })}</Heading>
<Text className="text-sm">{t("emails.email_customization_preview_email_text")}</Text>
</Container>
</EmailTemplate>
);

View File

@@ -17,10 +17,10 @@ export async function InviteAcceptedEmail({
return (
<EmailTemplate t={t}>
<Container>
<Heading className="text-xl">
<Heading>
{t("emails.invite_accepted_email_heading", { inviterName })} {inviterName}
</Heading>
<Text className="font-normal">
<Text className="text-sm">
{t("emails.invite_accepted_email_text_par1", { inviteeName })} {inviteeName}{" "}
{t("emails.invite_accepted_email_text_par2")}
</Text>

View File

@@ -20,10 +20,10 @@ export async function InviteEmail({
return (
<EmailTemplate t={t}>
<Container>
<Heading className="text-xl">
<Heading>
{t("emails.invite_email_heading", { inviteeName })} {inviteeName}
</Heading>
<Text className="font-normal">
<Text className="text-sm">
{t("emails.invite_email_text_par1", { inviterName })} {inviterName}{" "}
{t("emails.invite_email_text_par2")}
</Text>

View File

@@ -1,42 +0,0 @@
import { getTranslate } from "@/tolgee/server";
import { Container, Heading, Text } from "@react-email/components";
import { EmailButton } from "../../components/email-button";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
interface OnboardingInviteEmailProps {
inviteMessage: string;
inviterName: string;
verifyLink: string;
inviteeName: string;
}
export async function OnboardingInviteEmail({
inviteMessage,
inviterName,
verifyLink,
inviteeName,
}: OnboardingInviteEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate t={t}>
<Container>
<Heading>{t("emails.onboarding_invite_email_heading", { inviteeName })}</Heading>
<Text>{inviteMessage}</Text>
<Text className="font-medium">{t("emails.onboarding_invite_email_get_started_in_minutes")}</Text>
<ol>
<li>{t("emails.onboarding_invite_email_create_account", { inviterName })}</li>
<li>{t("emails.onboarding_invite_email_connect_formbricks")}</li>
<li>{t("emails.onboarding_invite_email_done")} </li>
</ol>
<EmailButton
href={verifyLink}
label={t("emails.onboarding_invite_email_button_label", { inviterName })}
/>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default OnboardingInviteEmail;

View File

@@ -83,10 +83,10 @@ describe("renderEmailResponseValue", () => {
expect(screen.getByText(expectedMessage)).toBeInTheDocument();
expect(screen.getByText(expectedMessage)).toHaveClass(
"mt-0",
"font-bold",
"break-words",
"whitespace-pre-wrap",
"italic"
"italic",
"text-sm"
);
});
});
@@ -225,7 +225,7 @@ ${"This is a very long sentence that should wrap properly within the email layou
// Check if the text has the expected styling classes
const textElement = screen.getByText(response);
expect(textElement).toHaveClass("mt-0", "font-bold", "break-words", "whitespace-pre-wrap");
expect(textElement).toHaveClass("mt-0", "break-words", "whitespace-pre-wrap", "text-sm");
});
test("handles array responses in the default case by rendering them as text", async () => {
@@ -248,7 +248,7 @@ ${"This is a very long sentence that should wrap properly within the email layou
// Check if the text element contains all items from the response array
const textElement = container.querySelector("p");
expect(textElement).not.toBeNull();
expect(textElement).toHaveClass("mt-0", "font-bold", "break-words", "whitespace-pre-wrap");
expect(textElement).toHaveClass("mt-0", "break-words", "whitespace-pre-wrap", "text-sm");
// Verify each item is present in the text content
response.forEach((item) => {

View File

@@ -15,18 +15,20 @@ export const renderEmailResponseValue = async (
return (
<Container>
{overrideFileUploadResponse ? (
<Text className="mt-0 whitespace-pre-wrap break-words font-bold italic">
<Text className="mt-0 whitespace-pre-wrap break-words text-sm italic">
{t("emails.render_email_response_value_file_upload_response_link_not_included")}
</Text>
) : (
Array.isArray(response) &&
response.map((responseItem) => (
<Link
className="mt-2 flex flex-col items-center justify-center rounded-lg bg-slate-200 p-2 text-black shadow-sm"
className="mt-2 flex flex-col items-center justify-center rounded-lg bg-slate-200 p-2 text-sm text-black shadow-sm"
href={responseItem}
key={responseItem}>
<FileIcon />
<Text className="mx-auto mb-0 truncate">{getOriginalFileNameFromUrl(responseItem)}</Text>
<FileIcon className="h-4 w-4" />
<Text className="mx-auto mb-0 truncate text-sm">
{getOriginalFileNameFromUrl(responseItem)}
</Text>
</Link>
))
)}
@@ -50,7 +52,7 @@ export const renderEmailResponseValue = async (
case TSurveyQuestionTypeEnum.Ranking:
return (
<Container>
<Row className="my-1 font-semibold text-slate-700" dir="auto">
<Row className="mb-2 text-sm text-slate-700" dir="auto">
{Array.isArray(response) &&
response.map(
(item, index) =>
@@ -66,6 +68,6 @@ export const renderEmailResponseValue = async (
);
default:
return <Text className="mt-0 whitespace-pre-wrap break-words font-bold">{response}</Text>;
return <Text className="mt-0 whitespace-pre-wrap break-words text-sm">{response}</Text>;
}
};

View File

@@ -18,13 +18,13 @@ export async function EmbedSurveyPreviewEmail({
return (
<EmailTemplate logoUrl={logoUrl} t={t}>
<Container>
<Heading className="text-xl">{t("emails.embed_survey_preview_email_heading")}</Heading>
<Text className="font-normal">{t("emails.embed_survey_preview_email_text")}</Text>
<Text className="text-sm font-normal">
<Heading>{t("emails.embed_survey_preview_email_heading")}</Heading>
<Text className="text-sm">{t("emails.embed_survey_preview_email_text")}</Text>
<Text className="text-sm">
<b>{t("emails.embed_survey_preview_email_didnt_request")}</b>{" "}
{t("emails.embed_survey_preview_email_fight_spam")}
</Text>
<div dangerouslySetInnerHTML={{ __html: html }} />
<div className="text-sm" dangerouslySetInnerHTML={{ __html: html }} />
<Text className="text-center text-sm text-slate-700">
{t("emails.embed_survey_preview_email_environment_id")}: {environmentId}
</Text>

View File

@@ -20,11 +20,11 @@ export async function LinkSurveyEmail({
return (
<EmailTemplate logoUrl={logoUrl} t={t}>
<Container>
<Heading className="text-xl">{t("emails.verification_email_hey")}</Heading>
<Text className="font-normal">{t("emails.verification_email_thanks")}</Text>
<Text className="font-normal">{t("emails.verification_email_to_fill_survey")}</Text>
<Heading>{t("emails.verification_email_hey")}</Heading>
<Text className="text-sm">{t("emails.verification_email_thanks")}</Text>
<Text className="text-sm">{t("emails.verification_email_to_fill_survey")}</Text>
<EmailButton href={surveyLink} label={t("emails.verification_email_take_survey")} />
<Text className="text-xs text-slate-400">
<Text className="text-sm text-slate-400">
{t("emails.verification_email_survey_name")}: {surveyName}
</Text>
<EmailFooter t={t} />

View File

@@ -1,7 +1,7 @@
import { getQuestionResponseMapping } from "@/lib/responses";
import { renderEmailResponseValue } from "@/modules/email/emails/lib/utils";
import { getTranslate } from "@/tolgee/server";
import { Column, Container, Hr, Link, Row, Section, Text } from "@react-email/components";
import { Column, Container, Heading, Hr, Link, Row, Section, Text } from "@react-email/components";
import { FileDigitIcon, FileType2Icon } from "lucide-react";
import type { TOrganization } from "@formbricks/types/organizations";
import type { TResponse } from "@formbricks/types/responses";
@@ -34,8 +34,8 @@ export async function ResponseFinishedEmail({
<Container>
<Row>
<Column>
<Text className="mb-4 text-xl font-bold"> {t("emails.survey_response_finished_email_hey")}</Text>
<Text className="mb-4 font-normal">
<Heading> {t("emails.survey_response_finished_email_hey")}</Heading>
<Text className="mb-4 text-sm">
{t("emails.survey_response_finished_email_congrats", {
surveyName: survey.name,
})}
@@ -45,8 +45,8 @@ export async function ResponseFinishedEmail({
if (!question.response) return;
return (
<Row key={question.question}>
<Column className="w-full">
<Text className="mb-2 font-medium">{question.question}</Text>
<Column className="w-full font-medium">
<Text className="mb-2 text-sm">{question.question}</Text>
{renderEmailResponseValue(question.response, question.type, t)}
</Column>
</Row>
@@ -57,8 +57,8 @@ export async function ResponseFinishedEmail({
if (variableResponse && ["number", "string"].includes(typeof variable)) {
return (
<Row key={variable.id}>
<Column className="w-full">
<Text className="mb-2 flex items-center gap-2 font-medium">
<Column className="w-full text-sm font-medium">
<Text className="mb-1 flex items-center gap-2">
{variable.type === "number" ? (
<FileDigitIcon className="h-4 w-4" />
) : (
@@ -66,7 +66,7 @@ export async function ResponseFinishedEmail({
)}
{variable.name}
</Text>
<Text className="mt-0 whitespace-pre-wrap break-words font-bold">
<Text className="mt-0 whitespace-pre-wrap break-words font-medium">
{variableResponse}
</Text>
</Column>
@@ -80,11 +80,11 @@ export async function ResponseFinishedEmail({
if (hiddenFieldResponse && typeof hiddenFieldResponse === "string") {
return (
<Row key={hiddenFieldId}>
<Column className="w-full">
<Text className="mb-2 flex items-center gap-2 font-medium">
<Column className="w-full font-medium">
<Text className="mb-2 flex items-center gap-2 text-sm">
{hiddenFieldId} <EyeOffIcon />
</Text>
<Text className="mt-0 whitespace-pre-wrap break-words font-bold">
<Text className="mt-0 whitespace-pre-wrap break-words text-sm">
{hiddenFieldResponse}
</Text>
</Column>
@@ -105,19 +105,19 @@ export async function ResponseFinishedEmail({
/>
<Hr />
<Section className="mt-4 text-center text-sm">
<Text className="font-bold">
<Text className="text-sm font-medium">
{t("emails.survey_response_finished_email_dont_want_notifications")}
</Text>
<Text className="mb-0">
<Link
className="text-black underline"
className="text-sm text-black underline"
href={`${WEBAPP_URL}/environments/${environmentId}/settings/notifications?type=alert&elementId=${survey.id}`}>
{t("emails.survey_response_finished_email_turn_off_notifications_for_this_form")}
</Link>
</Text>
<Text className="mt-0">
<Link
className="text-black underline"
className="text-sm text-black underline"
href={`${WEBAPP_URL}/environments/${environmentId}/settings/notifications?type=unsubscribedOrganizationIds&elementId=${organization.id}`}>
{t("emails.survey_response_finished_email_turn_off_notifications_for_all_new_forms")}
</Link>

View File

@@ -16,19 +16,19 @@ export async function CreateReminderNotificationBody({
const t = await getTranslate();
return (
<Container>
<Text>
<Text className="text-sm">
{t("emails.weekly_summary_create_reminder_notification_body_text", {
projectName: notificationData.projectName,
})}
</Text>
<Text className="pt-4 font-bold">
<Text className="pt-4 text-sm font-medium">
{t("emails.weekly_summary_create_reminder_notification_body_dont_let_a_week_pass")}
</Text>
<EmailButton
href={`${WEBAPP_URL}/environments/${notificationData.environmentId}/surveys?utm_source=weekly&utm_medium=email&utm_content=SetupANewSurveyCTA`}
label={t("emails.weekly_summary_create_reminder_notification_body_setup_a_new_survey")}
/>
<Text className="pt-4">
<Text className="pt-4 text-sm">
{t("emails.weekly_summary_create_reminder_notification_body_need_help")}
<a href="https://cal.com/johannes/15">
{t("emails.weekly_summary_create_reminder_notification_body_cal_slot")}

View File

@@ -48,7 +48,9 @@ export async function LiveSurveyNotification({
if (surveyResponses.length === 0) {
return (
<Container className="mt-4">
<Text className="m-0 font-bold">{t("emails.live_survey_notification_no_responses_yet")}</Text>
<Text className="m-0 text-sm font-medium">
{t("emails.live_survey_notification_no_responses_yet")}
</Text>
</Container>
);
}
@@ -62,7 +64,7 @@ export async function LiveSurveyNotification({
surveyFields.push(
<Container className="mt-4" key={`${index.toString()}-${surveyResponse.headline}`}>
<Text className="m-0">{surveyResponse.headline}</Text>
<Text className="m-0 text-sm">{surveyResponse.headline}</Text>
{renderEmailResponseValue(surveyResponse.responseValue, surveyResponse.questionType, t)}
</Container>
);
@@ -87,7 +89,7 @@ export async function LiveSurveyNotification({
<Container className="mt-12">
<Text className="mb-0 inline">
<Link
className="text-xl text-black underline"
className="text-sm text-black underline"
href={`${WEBAPP_URL}/environments/${environmentId}/surveys/${survey.id}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA`}>
{survey.name}
</Link>
@@ -98,7 +100,7 @@ export async function LiveSurveyNotification({
{displayStatus}
</Text>
{noResponseLastWeek ? (
<Text>{t("emails.live_survey_notification_no_new_response")}</Text>
<Text className="text-sm">{t("emails.live_survey_notification_no_new_response")}</Text>
) : (
createSurveyFields(survey.responses)
)}

View File

@@ -13,15 +13,15 @@ export async function NotificationFooter({
return (
<Tailwind>
<Container className="w-full">
<Text className="mb-0 pt-4 font-medium">{t("emails.notification_footer_all_the_best")}</Text>
<Text className="mt-0">{t("emails.notification_footer_the_formbricks_team")}</Text>
<Text className="mb-0 pt-4 text-sm font-medium">{t("emails.notification_footer_all_the_best")}</Text>
<Text className="mt-0 text-sm">{t("emails.notification_footer_the_formbricks_team")}</Text>
<Container
className="mt-0 w-full rounded-md bg-slate-100 px-4 text-center text-xs leading-5"
style={{ fontStyle: "italic" }}>
<Text>
<Text className="text-sm">
{t("emails.notification_footer_to_halt_weekly_updates")}
<Link
className="text-black underline"
className="text-sm text-black underline"
href={`${WEBAPP_URL}/environments/${environmentId}/settings/notifications`}>
{t("emails.notification_footer_please_turn_them_off")}
</Link>{" "}

View File

@@ -18,17 +18,17 @@ export async function NotificationHeader({
endYear,
}: NotificationHeaderProps): Promise<React.JSX.Element> {
const t = await getTranslate();
const getNotificationHeaderimePeriod = (): React.JSX.Element => {
const getNotificationHeaderTimePeriod = (): React.JSX.Element => {
if (startYear === endYear) {
return (
<Text className="m-0 text-right">
<Text className="m-0 text-right text-sm">
{startDate} - {endDate} {endYear}
</Text>
);
}
return (
<Text className="m-0 text-right">
<Text className="m-0 text-right text-sm">
{startDate} {startYear} - {endDate} {endYear}
</Text>
);
@@ -40,10 +40,10 @@ export async function NotificationHeader({
<Heading className="m-0">{t("emails.notification_header_hey")}</Heading>
</div>
<div className="float-right">
<Text className="m-0 text-right font-semibold">
<Text className="m-0 text-right text-sm font-medium">
{t("emails.notification_header_weekly_report_for")} {projectName}
</Text>
{getNotificationHeaderimePeriod()}
{getNotificationHeaderTimePeriod()}
</div>
</div>
</Container>

View File

@@ -17,24 +17,24 @@ export async function NotificationInsight({
<Row>
<Column className="text-center">
<Text className="text-sm">{t("emails.notification_insight_surveys")}</Text>
<Text className="text-lg font-bold">{insights.numLiveSurvey}</Text>
<Text className="text-sm font-medium">{insights.numLiveSurvey}</Text>
</Column>
<Column className="text-center">
<Text className="text-sm">{t("emails.notification_insight_displays")}</Text>
<Text className="text-lg font-bold">{insights.totalDisplays}</Text>
<Text className="text-sm font-medium">{insights.totalDisplays}</Text>
</Column>
<Column className="text-center">
<Text className="text-sm">{t("emails.notification_insight_responses")}</Text>
<Text className="text-lg font-bold">{insights.totalResponses}</Text>
<Text className="text-sm font-medium">{insights.totalResponses}</Text>
</Column>
<Column className="text-center">
<Text className="text-sm">{t("emails.notification_insight_completed")}</Text>
<Text className="text-lg font-bold">{insights.totalCompletedResponses}</Text>
<Text className="text-sm font-medium">{insights.totalCompletedResponses}</Text>
</Column>
{insights.totalDisplays !== 0 ? (
<Column className="text-center">
<Text className="text-sm">{t("emails.notification_insight_completion_rate")}</Text>
<Text className="text-lg font-bold">{Math.round(insights.completionRate)}%</Text>
<Text className="text-sm font-medium">{Math.round(insights.completionRate)}%</Text>
</Column>
) : (
""

View File

@@ -32,7 +32,6 @@ import { PasswordResetNotifyEmail } from "./emails/auth/password-reset-notify-em
import { VerificationEmail } from "./emails/auth/verification-email";
import { InviteAcceptedEmail } from "./emails/invite/invite-accepted-email";
import { InviteEmail } from "./emails/invite/invite-email";
import { OnboardingInviteEmail } from "./emails/invite/onboarding-invite-email";
import { EmbedSurveyPreviewEmail } from "./emails/survey/embed-survey-preview-email";
import { LinkSurveyEmail } from "./emails/survey/link-survey-email";
import { ResponseFinishedEmail } from "./emails/survey/response-finished-email";
@@ -166,9 +165,7 @@ export const sendInviteMemberEmail = async (
inviteId: string,
email: string,
inviterName: string,
inviteeName: string,
isOnboardingInvite?: boolean,
inviteMessage?: string
inviteeName: string
): Promise<boolean> => {
const token = createInviteToken(inviteId, email, {
expiresIn: "7d",
@@ -177,26 +174,12 @@ export const sendInviteMemberEmail = async (
const verifyLink = `${WEBAPP_URL}/invite?token=${encodeURIComponent(token)}`;
if (isOnboardingInvite && inviteMessage) {
const html = await render(
await OnboardingInviteEmail({ verifyLink, inviteMessage, inviterName, inviteeName })
);
return await sendEmail({
to: email,
subject: t("emails.onboarding_invite_email_subject", {
inviterName,
}),
html,
});
} else {
const t = await getTranslate();
const html = await render(await InviteEmail({ inviteeName, inviterName, verifyLink }));
return await sendEmail({
to: email,
subject: t("emails.invite_member_email_subject"),
html,
});
}
const html = await render(await InviteEmail({ inviteeName, inviterName, verifyLink }));
return await sendEmail({
to: email,
subject: t("emails.invite_member_email_subject"),
html,
});
};
export const sendInviteAcceptedEmail = async (

View File

@@ -188,9 +188,7 @@ export const resendInviteAction = authenticatedActionClient.schema(ZResendInvite
parsedInput.inviteId,
updatedInvite.email,
invite?.creator?.name ?? "",
updatedInvite.name ?? "",
undefined,
ctx.user.locale
updatedInvite.name ?? ""
);
return updatedInvite;
}
@@ -266,14 +264,7 @@ export const inviteUserAction = authenticatedActionClient.schema(ZInviteUserActi
};
if (inviteId) {
await sendInviteMemberEmail(
inviteId,
parsedInput.email,
ctx.user.name ?? "",
parsedInput.name ?? "",
false,
undefined
);
await sendInviteMemberEmail(inviteId, parsedInput.email, ctx.user.name ?? "", parsedInput.name ?? "");
}
return inviteId;

View File

@@ -57,14 +57,7 @@ export const inviteOrganizationMemberAction = authenticatedActionClient
currentUserId: ctx.user.id,
});
await sendInviteMemberEmail(
invitedUserId,
parsedInput.email,
ctx.user.name,
"",
false, // is onboarding invite
undefined
);
await sendInviteMemberEmail(invitedUserId, parsedInput.email, ctx.user.name, "");
ctx.auditLoggingCtx.inviteId = invitedUserId;
ctx.auditLoggingCtx.newObject = {

View File

@@ -76,8 +76,8 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JS
if (!question.response) return;
return (
<Row key={question.question}>
<Column className="w-full">
<Text className="mb-2 font-medium">{question.question}</Text>
<Column className="w-full font-medium">
<Text className="mb-2 text-sm">{question.question}</Text>
{renderEmailResponseValue(question.response, question.type, t, true)}
</Column>
</Row>
@@ -89,22 +89,22 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JS
{isDefaultLogo ? (
<Section className="mt-4 text-center text-sm">
<Link
className="m-0 font-normal text-slate-500"
className="m-0 text-sm text-slate-500"
href="https://formbricks.com/?utm_source=email_header&utm_medium=email"
target="_blank"
rel="noopener noreferrer">
{t("emails.email_template_text_1")}
</Link>
{IMPRINT_ADDRESS && (
<Text className="m-0 font-normal text-slate-500 opacity-50">{IMPRINT_ADDRESS}</Text>
<Text className="m-0 text-sm text-slate-500 opacity-50">{IMPRINT_ADDRESS}</Text>
)}
<Text className="m-0 font-normal text-slate-500 opacity-50">
<Text className="m-0 text-sm text-slate-500 opacity-50">
{IMPRINT_URL && (
<Link
href={IMPRINT_URL}
target="_blank"
rel="noopener noreferrer"
className="text-slate-500">
className="text-sm text-slate-500">
{t("emails.imprint")}
</Link>
)}
@@ -114,7 +114,7 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JS
href={PRIVACY_URL}
target="_blank"
rel="noopener noreferrer"
className="text-slate-500">
className="text-sm text-slate-500">
{t("emails.privacy_policy")}
</Link>
)}

View File

@@ -11,7 +11,7 @@ const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = ({ children, ...props }: DialogPrimitive.DialogPortalProps) => (
<DialogPrimitive.Portal {...props}>
<div className="fixed inset-0 z-50 flex items-end justify-center md:items-center">{children}</div>
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">{children}</div>
</DialogPrimitive.Portal>
);
DialogPortal.displayName = DialogPrimitive.Portal.displayName;
@@ -41,11 +41,11 @@ interface DialogContentProps {
const getDialogWidthClass = (width: "default" | "wide" | "narrow"): string => {
switch (width) {
case "wide":
return "md:w-[720px] lg:w-[960px]";
return "sm:w-[90dvw] md:w-[720px] lg:w-[960px]";
case "narrow":
return "md:w-[512px]";
return "sm:w-[512px]";
default:
return "md:w-[720px]";
return "sm:w-[90dvw] md:w-[720px]";
}
};
@@ -73,8 +73,8 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 md:zoom-in-90 data-[state=open]:md:slide-in-from-bottom-0 fixed z-50 flex max-h-[90dvh] w-full flex-col space-y-4 rounded-t-lg border bg-white p-4 shadow-lg md:rounded-lg",
!unconstrained && "md:overflow-hidden",
"animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 md:zoom-in-90 data-[state=open]:md:slide-in-from-bottom-0 fixed z-50 flex max-h-[90dvh] w-full flex-col space-y-4 rounded-t-lg border bg-white p-4 shadow-lg sm:rounded-lg",
!unconstrained && "sm:overflow-hidden",
widthClass,
className
)}
@@ -105,7 +105,7 @@ const DialogHeader = ({ className, ...props }: DialogHeaderProps) => (
<div
className={cn(
"sticky top-[-32px] z-10 flex flex-shrink-0 flex-col gap-y-1 bg-white text-left",
"[&>svg]:text-primary [&>svg]:absolute [&>svg]:size-4 [&>svg~*]:min-h-4 [&>svg~*]:items-center [&>svg~*]:pl-6 md:[&>svg~*]:flex",
"[&>svg]:text-primary [&>svg]:absolute [&>svg]:size-4 [&>svg~*]:min-h-4 [&>svg~*]:items-center [&>svg~*]:pl-6 sm:[&>svg~*]:flex",
className
)}
{...props}
@@ -122,7 +122,7 @@ type DialogFooterProps = Omit<React.HTMLAttributes<HTMLDivElement>, "dangerously
const DialogFooter = ({ className, ...props }: DialogFooterProps) => (
<div
className={cn(
"bottom-0 z-10 flex flex-shrink-0 flex-col-reverse gap-2 bg-white md:sticky md:flex-row md:justify-end",
"bottom-0 z-10 flex flex-shrink-0 flex-col-reverse gap-2 bg-white sm:sticky sm:flex-row sm:justify-end",
className
)}
{...props}

View File

@@ -0,0 +1,13 @@
export const FacebookIcon: React.FC<React.SVGProps<SVGSVGElement>> = () => {
return (
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12.5003 1.33301H10.5003C9.61627 1.33301 8.76842 1.6842 8.1433 2.30932C7.51818 2.93444 7.16699 3.78229 7.16699 4.66634V6.66634H5.16699V9.33301H7.16699V14.6663H9.83366V9.33301H11.8337L12.5003 6.66634H9.83366V4.66634C9.83366 4.48953 9.9039 4.31996 10.0289 4.19494C10.1539 4.06991 10.3235 3.99967 10.5003 3.99967H12.5003V1.33301Z"
stroke="#0F172A"
strokeWidth="1.33"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};

View File

@@ -0,0 +1,27 @@
export const LinkedinIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
return (
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M11.1663 5.33301C12.2272 5.33301 13.2446 5.75444 13.9948 6.50458C14.7449 7.25473 15.1663 8.27214 15.1663 9.33301V13.9997H12.4997V9.33301C12.4997 8.97939 12.3592 8.64025 12.1092 8.3902C11.8591 8.14015 11.52 7.99967 11.1663 7.99967C10.8127 7.99967 10.4736 8.14015 10.2235 8.3902C9.97348 8.64025 9.83301 8.97939 9.83301 9.33301V13.9997H7.16634V9.33301C7.16634 8.27214 7.58777 7.25473 8.33791 6.50458C9.08806 5.75444 10.1055 5.33301 11.1663 5.33301Z"
stroke="#0F172A"
strokeWidth="1.33"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M4.49967 5.99967H1.83301V13.9997H4.49967V5.99967Z"
stroke="#0F172A"
strokeWidth="1.33"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3.16634 3.99967C3.90272 3.99967 4.49967 3.40272 4.49967 2.66634C4.49967 1.92996 3.90272 1.33301 3.16634 1.33301C2.42996 1.33301 1.83301 1.92996 1.83301 2.66634C1.83301 3.40272 2.42996 3.99967 3.16634 3.99967Z"
stroke="#0F172A"
strokeWidth="1.33"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};

View File

@@ -0,0 +1,31 @@
export const RedditIcon: React.FC<React.SVGProps<SVGSVGElement>> = () => {
return (
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_3422_4746)">
<path
d="M6.03565 7.71686C5.51252 7.71686 5.06252 8.23686 5.03127 8.91436C5.00002 9.59186 5.45815 9.86749 5.9819 9.86749C6.50565 9.86749 6.89627 9.62124 6.92752 8.94374C6.95877 8.26624 6.55877 7.71686 6.03565 7.71686Z"
fill="#0F172A"
/>
<path
d="M11.9769 8.91436C11.9463 8.23686 11.4963 7.71686 10.9725 7.71686C10.4488 7.71686 10.0494 8.26624 10.0806 8.94374C10.1119 9.62186 10.5031 9.86749 11.0263 9.86749C11.5494 9.86749 12.0081 9.59186 11.9769 8.91436Z"
fill="#0F172A"
/>
<path
d="M10.4725 10.685C10.5106 10.5937 10.4481 10.4919 10.35 10.4819C9.7744 10.4237 9.15315 10.3919 8.50377 10.3919C7.8544 10.3919 7.23252 10.4237 6.65752 10.4819C6.5594 10.4919 6.4969 10.5937 6.53502 10.685C6.85752 11.4544 7.6169 11.995 8.50377 11.995C9.39065 11.995 10.1506 11.4544 10.4725 10.685Z"
fill="#0F172A"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.5 8C0.5 3.58187 4.08187 0 8.5 0C12.9181 0 16.5 3.58187 16.5 8C16.5 12.4181 12.9181 16 8.5 16H1.65875C1.23125 16 1.01687 15.4831 1.31937 15.1806L2.84313 13.6569C1.39563 12.2094 0.5 10.2094 0.5 8ZM11.425 4.79874C10.7944 4.79874 10.2663 4.36061 10.1275 3.77249V3.77374C9.36252 3.88186 8.77252 4.54061 8.77252 5.33436V5.33936C9.9569 5.38374 11.0394 5.71749 11.8969 6.24811C12.2119 6.00561 12.6063 5.86124 13.0344 5.86124C14.0663 5.86124 14.9025 6.69749 14.9025 7.72936C14.9025 8.47374 14.4663 9.11686 13.8356 9.41686C13.7769 11.5875 11.4106 13.3337 8.50377 13.3337C5.5969 13.3337 3.23377 11.5894 3.1719 9.42061C2.53627 9.12249 2.09627 8.47749 2.09627 7.72874C2.09627 6.69686 2.93252 5.86061 3.9644 5.86061C4.3944 5.86061 4.79065 6.00624 5.10627 6.25061C5.95627 5.72374 7.0269 5.38999 8.1994 5.34061V5.33374C8.1994 4.22561 9.04127 3.31124 10.1194 3.19561C10.2444 2.58874 10.7813 2.13249 11.425 2.13249C12.1613 2.13249 12.7581 2.72936 12.7581 3.46561C12.7581 4.20186 12.1613 4.79874 11.425 4.79874Z"
fill="#0F172A"
/>
</g>
<defs>
<clipPath id="clip0_3422_4746">
<rect width="16" height="16" fill="white" transform="translate(0.5)" />
</clipPath>
</defs>
</svg>
);
};

View File

@@ -0,0 +1,10 @@
export const ThreadsIcon: React.FC<React.SVGProps<SVGSVGElement>> = () => {
return (
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12.2944 7.41569C12.2255 7.38266 12.1555 7.35087 12.0846 7.32043C11.9611 5.04485 10.7177 3.74208 8.62983 3.72875C8.62037 3.72869 8.61097 3.72869 8.60151 3.72869C7.35271 3.72869 6.3141 4.26174 5.67484 5.23173L6.82309 6.0194C7.30064 5.29486 8.05011 5.1404 8.60206 5.1404C8.60843 5.1404 8.61483 5.1404 8.62114 5.14046C9.30859 5.14484 9.82734 5.34472 10.1631 5.7345C10.4074 6.01828 10.5708 6.41042 10.6518 6.90532C10.0423 6.80173 9.38309 6.76988 8.67842 6.81028C6.6934 6.92462 5.41727 8.08232 5.50297 9.691C5.54647 10.507 5.95298 11.209 6.64759 11.6676C7.23487 12.0553 7.99126 12.2448 8.77737 12.2019C9.81551 12.145 10.6299 11.7489 11.1981 11.0247C11.6296 10.4747 11.9025 9.76192 12.023 8.86383C12.5178 9.16242 12.8844 9.55533 13.0869 10.0277C13.4313 10.8306 13.4513 12.15 12.3748 13.2257C11.4315 14.168 10.2977 14.5757 8.58413 14.5883C6.68336 14.5742 5.24582 13.9646 4.31118 12.7764C3.43597 11.6638 2.98365 10.0568 2.96677 8C2.98365 5.94315 3.43597 4.33613 4.31118 3.22356C5.24582 2.03541 6.68333 1.42583 8.58411 1.41171C10.4987 1.42594 11.9613 2.03845 12.9317 3.23233C13.4075 3.8178 13.7663 4.55407 14.0028 5.41253L15.3483 5.05352C15.0617 3.99685 14.6106 3.08631 13.9968 2.33117C12.7527 0.800557 10.9332 0.0162623 8.5888 0H8.57942C6.23981 0.0162058 4.44068 0.803483 3.23203 2.33994C2.1565 3.7072 1.60171 5.60964 1.58307 7.99438L1.58301 8L1.58307 8.00562C1.60171 10.3903 2.1565 12.2928 3.23203 13.6601C4.44068 15.1965 6.23981 15.9838 8.57942 16H8.5888C10.6688 15.9856 12.135 15.441 13.3428 14.2343C14.9231 12.6555 14.8755 10.6766 14.3547 9.46175C13.981 8.59058 13.2686 7.88302 12.2944 7.41569ZM8.70305 10.7923C7.83305 10.8413 6.92921 10.4508 6.88464 9.61433C6.85161 8.99417 7.32599 8.30217 8.75644 8.21973C8.92026 8.21028 9.08101 8.20567 9.23892 8.20567C9.75851 8.20567 10.2446 8.25614 10.6865 8.35275C10.5217 10.4113 9.55484 10.7455 8.70305 10.7923Z"
fill="#0F172A"
/>
</svg>
);
};

View File

@@ -0,0 +1,10 @@
export const XIcon: React.FC<React.SVGProps<SVGSVGElement>> = () => {
return (
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12.7178 1.26953H14.9668L10.0535 6.88519L15.8337 14.5268H11.3078L7.76302 9.89222L3.70696 14.5268H1.45661L6.71195 8.52026L1.16699 1.26953H5.80773L9.01192 5.50575L12.7178 1.26953ZM11.9285 13.1807H13.1747L5.13059 2.54495H3.7933L11.9285 13.1807Z"
fill="black"
/>
</svg>
);
};