mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-06 00:49:42 -06:00
feat: Personal links (#6138)
Co-authored-by: Johannes <johannes@formbricks.com>
This commit is contained in:
committed by
GitHub
parent
fef30c54b2
commit
4fdea3221b
@@ -1,13 +1,15 @@
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -33,6 +35,9 @@ const Page = async (props) => {
|
||||
|
||||
const tags = await getTagsByEnvironmentId(params.environmentId);
|
||||
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
||||
|
||||
// Get response count for the CTA component
|
||||
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
|
||||
@@ -51,6 +56,9 @@ const Page = async (props) => {
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={responseCount}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
"use server";
|
||||
|
||||
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { putFile } from "@/lib/storage/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { convertToCsv } from "@/lib/utils/file-conversion";
|
||||
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
|
||||
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
|
||||
|
||||
const ZSendEmbedSurveyPreviewEmailAction = z.object({
|
||||
surveyId: ZId,
|
||||
@@ -222,3 +227,89 @@ export const getEmailHtmlAction = authenticatedActionClient
|
||||
|
||||
return await getEmailTemplateHtml(parsedInput.surveyId, ctx.user.locale);
|
||||
});
|
||||
|
||||
const ZGeneratePersonalLinksAction = z.object({
|
||||
surveyId: ZId,
|
||||
segmentId: ZId,
|
||||
environmentId: ZId,
|
||||
expirationDays: z.number().optional(),
|
||||
});
|
||||
|
||||
export const generatePersonalLinksAction = authenticatedActionClient
|
||||
.schema(ZGeneratePersonalLinksAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
if (!isContactsEnabled) {
|
||||
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
|
||||
}
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Get contacts and generate personal links
|
||||
const contactsResult = await generatePersonalLinks(
|
||||
parsedInput.surveyId,
|
||||
parsedInput.segmentId,
|
||||
parsedInput.expirationDays
|
||||
);
|
||||
|
||||
if (!contactsResult || contactsResult.length === 0) {
|
||||
throw new UnknownError("No contacts found for the selected segment");
|
||||
}
|
||||
|
||||
// Prepare CSV data with the specified headers and order
|
||||
const csvHeaders = [
|
||||
"Formbricks Contact ID",
|
||||
"User ID",
|
||||
"First Name",
|
||||
"Last Name",
|
||||
"Email",
|
||||
"Personal Link",
|
||||
];
|
||||
|
||||
const csvData = contactsResult
|
||||
.map((contact) => {
|
||||
if (!contact) {
|
||||
return null;
|
||||
}
|
||||
const attributes = contact.attributes ?? {};
|
||||
return {
|
||||
"Formbricks Contact ID": contact.contactId,
|
||||
"User ID": attributes.userId ?? "",
|
||||
"First Name": attributes.firstName ?? "",
|
||||
"Last Name": attributes.lastName ?? "",
|
||||
Email: attributes.email ?? "",
|
||||
"Personal Link": contact.surveyUrl,
|
||||
};
|
||||
})
|
||||
.filter((contact) => contact !== null);
|
||||
|
||||
// Convert to CSV using the file conversion utility
|
||||
const csvContent = await convertToCsv(csvHeaders, csvData);
|
||||
const fileName = `personal-links-${parsedInput.surveyId}-${Date.now()}.csv`;
|
||||
|
||||
// Store file temporarily and return download URL
|
||||
const fileBuffer = Buffer.from(csvContent);
|
||||
await putFile(fileName, fileBuffer, "private", parsedInput.environmentId);
|
||||
|
||||
const downloadUrl = `${WEBAPP_URL}/storage/${parsedInput.environmentId}/private/${fileName}`;
|
||||
|
||||
return {
|
||||
downloadUrl,
|
||||
fileName,
|
||||
count: csvData.length,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -117,9 +117,9 @@ vi.mock("./shareEmbedModal/EmbedView", () => ({
|
||||
EmbedView: (props: any) => mockEmbedViewComponent(props),
|
||||
}));
|
||||
|
||||
const mockPanelInfoViewComponent = vi.fn();
|
||||
vi.mock("./shareEmbedModal/PanelInfoView", () => ({
|
||||
PanelInfoView: (props: any) => mockPanelInfoViewComponent(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;
|
||||
@@ -133,8 +133,6 @@ vi.mock("@/modules/ui/components/dialog", async () => {
|
||||
capturedDialogOnOpenChange = props.onOpenChange;
|
||||
return <actual.Dialog {...props} />;
|
||||
},
|
||||
// DialogTitle, DialogContent, DialogDescription will be the actual components
|
||||
// due to ...actual spread and no specific mock for them here.
|
||||
};
|
||||
});
|
||||
|
||||
@@ -154,13 +152,15 @@ describe("ShareEmbedSurvey", () => {
|
||||
modalView: "start" as "start" | "embed" | "panel",
|
||||
setOpen: mockSetOpen,
|
||||
user: mockUser,
|
||||
segments: [],
|
||||
isContactsEnabled: true,
|
||||
isFormbricksCloud: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockEmbedViewComponent.mockImplementation(
|
||||
({ handleInitialPageButton, tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => (
|
||||
({ tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => (
|
||||
<div>
|
||||
<button onClick={() => handleInitialPageButton()}>EmbedViewMockContent</button>
|
||||
<div data-testid="embedview-tabs">{JSON.stringify(tabs)}</div>
|
||||
<div data-testid="embedview-activeid">{activeId}</div>
|
||||
<div data-testid="embedview-survey-id">{survey.id}</div>
|
||||
@@ -171,9 +171,6 @@ describe("ShareEmbedSurvey", () => {
|
||||
</div>
|
||||
)
|
||||
);
|
||||
mockPanelInfoViewComponent.mockImplementation(({ handleInitialPageButton }) => (
|
||||
<button onClick={() => handleInitialPageButton()}>PanelInfoViewMockContent</button>
|
||||
));
|
||||
});
|
||||
|
||||
test("renders initial 'start' view correctly when open and modalView is 'start' for link survey", () => {
|
||||
@@ -205,43 +202,15 @@ describe("ShareEmbedSurvey", () => {
|
||||
const embedButton = screen.getByText("environments.surveys.summary.embed_survey");
|
||||
await userEvent.click(embedButton);
|
||||
expect(mockEmbedViewComponent).toHaveBeenCalled();
|
||||
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("switches to 'panel' view when 'Send to panel' button is clicked", async () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} />);
|
||||
const panelButton = screen.getByText("environments.surveys.summary.send_to_panel");
|
||||
await userEvent.click(panelButton);
|
||||
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
|
||||
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("returns to 'start' view when handleInitialPageButton is triggered from EmbedView", async () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
|
||||
expect(mockEmbedViewComponent).toHaveBeenCalled();
|
||||
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
|
||||
|
||||
const embedViewButton = screen.getByText("EmbedViewMockContent");
|
||||
await userEvent.click(embedViewButton);
|
||||
|
||||
// Should go back to start view, not close the modal
|
||||
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
|
||||
expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument();
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns to 'start' view when handleInitialPageButton is triggered from PanelInfoView", async () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="panel" />);
|
||||
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
|
||||
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
|
||||
|
||||
const panelInfoViewButton = screen.getByText("PanelInfoViewMockContent");
|
||||
await userEvent.click(panelInfoViewButton);
|
||||
|
||||
// Should go back to start view, not close the modal
|
||||
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
|
||||
expect(screen.queryByText("PanelInfoViewMockContent")).not.toBeInTheDocument();
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
// Panel view currently just shows a title, no component is rendered
|
||||
expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handleOpenChange (when Dialog calls its onOpenChange prop)", () => {
|
||||
@@ -267,7 +236,7 @@ describe("ShareEmbedSurvey", () => {
|
||||
tabs: { id: string; label: string; icon: LucideIcon }[];
|
||||
activeId: string;
|
||||
};
|
||||
expect(embedViewProps.tabs.length).toBe(3);
|
||||
expect(embedViewProps.tabs.length).toBe(4);
|
||||
expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeUndefined();
|
||||
expect(embedViewProps.tabs[0].id).toBe("link");
|
||||
expect(embedViewProps.activeId).toBe("link");
|
||||
@@ -297,24 +266,21 @@ describe("ShareEmbedSurvey", () => {
|
||||
test("initial showView is set by modalView prop when open is true", () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
|
||||
expect(mockEmbedViewComponent).toHaveBeenCalled();
|
||||
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument();
|
||||
cleanup();
|
||||
|
||||
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="panel" />);
|
||||
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
|
||||
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
|
||||
// Panel view currently just shows a title
|
||||
expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("useEffect sets showView to 'start' when open becomes false", () => {
|
||||
const { rerender } = render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
|
||||
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument(); // Starts in embed
|
||||
expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument(); // Starts in embed
|
||||
|
||||
rerender(<ShareEmbedSurvey {...defaultProps} open={false} modalView="embed" />);
|
||||
// Dialog mock returns null when open is false, so EmbedViewMockContent is not found
|
||||
expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument();
|
||||
// To verify showView is 'start', we'd need to inspect internal state or render start view elements
|
||||
// For now, we trust the useEffect sets showView, and if it were to re-open in 'start' mode, it would show.
|
||||
// The main check is that the previous view ('embed') is gone.
|
||||
expect(screen.queryByTestId("embedview-tabs")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correct label for link tab based on singleUse survey property", () => {
|
||||
|
||||
@@ -12,15 +12,16 @@ import {
|
||||
LinkIcon,
|
||||
MailIcon,
|
||||
SmartphoneIcon,
|
||||
UserIcon,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { EmbedView } from "./shareEmbedModal/EmbedView";
|
||||
import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
|
||||
|
||||
interface ShareEmbedSurveyProps {
|
||||
survey: TSurvey;
|
||||
@@ -29,6 +30,9 @@ interface ShareEmbedSurveyProps {
|
||||
modalView: "start" | "embed" | "panel";
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
user: TUser;
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
export const ShareEmbedSurvey = ({
|
||||
@@ -38,6 +42,9 @@ export const ShareEmbedSurvey = ({
|
||||
modalView,
|
||||
setOpen,
|
||||
user,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
}: ShareEmbedSurveyProps) => {
|
||||
const router = useRouter();
|
||||
const environmentId = survey.environmentId;
|
||||
@@ -52,6 +59,7 @@ export const ShareEmbedSurvey = ({
|
||||
label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`,
|
||||
icon: LinkIcon,
|
||||
},
|
||||
{ id: "personal-links", label: t("environments.surveys.summary.personal_links"), icon: UserIcon },
|
||||
{ id: "email", label: t("environments.surveys.summary.embed_in_an_email"), icon: MailIcon },
|
||||
{ id: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon },
|
||||
|
||||
@@ -60,8 +68,8 @@ export const ShareEmbedSurvey = ({
|
||||
[t, isSingleUseLinkSurvey, survey.type]
|
||||
);
|
||||
|
||||
const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[3].id);
|
||||
const [showView, setShowView] = useState<"start" | "embed" | "panel">("start");
|
||||
const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[4].id);
|
||||
const [showView, setShowView] = useState<"start" | "embed" | "panel" | "personal-links">("start");
|
||||
const [surveyUrl, setSurveyUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -80,7 +88,7 @@ export const ShareEmbedSurvey = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (survey.type !== "link") {
|
||||
setActiveId(tabs[3].id);
|
||||
setActiveId(tabs[4].id);
|
||||
}
|
||||
}, [survey.type, tabs]);
|
||||
|
||||
@@ -93,7 +101,7 @@ export const ShareEmbedSurvey = ({
|
||||
}, [open, modalView]);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setActiveId(survey.type === "link" ? tabs[0].id : tabs[3].id);
|
||||
setActiveId(survey.type === "link" ? tabs[0].id : tabs[4].id);
|
||||
setOpen(open);
|
||||
if (!open) {
|
||||
setShowView("start");
|
||||
@@ -101,10 +109,6 @@ export const ShareEmbedSurvey = ({
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleInitialPageButton = () => {
|
||||
setShowView("start");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="w-full bg-white p-0 lg:h-[700px]" width="wide">
|
||||
@@ -166,22 +170,28 @@ export const ShareEmbedSurvey = ({
|
||||
</div>
|
||||
</div>
|
||||
) : showView === "embed" ? (
|
||||
<EmbedView
|
||||
handleInitialPageButton={handleInitialPageButton}
|
||||
tabs={survey.type === "link" ? tabs : [tabs[3]]}
|
||||
disableBack={false}
|
||||
activeId={activeId}
|
||||
environmentId={environmentId}
|
||||
setActiveId={setActiveId}
|
||||
survey={survey}
|
||||
email={email}
|
||||
surveyUrl={surveyUrl}
|
||||
publicDomain={publicDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={user.locale}
|
||||
/>
|
||||
<>
|
||||
<DialogTitle className="sr-only">{t("environments.surveys.summary.embed_survey")}</DialogTitle>
|
||||
<EmbedView
|
||||
tabs={survey.type === "link" ? tabs : [tabs[4]]}
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
) : showView === "panel" ? (
|
||||
<PanelInfoView handleInitialPageButton={handleInitialPageButton} disableBack={false} />
|
||||
<>
|
||||
<DialogTitle className="sr-only">{t("environments.surveys.summary.send_to_panel")}</DialogTitle>
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
@@ -25,6 +26,9 @@ interface SurveyAnalysisCTAProps {
|
||||
user: TUser;
|
||||
publicDomain: string;
|
||||
responseCount: number;
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
interface ModalState {
|
||||
@@ -41,6 +45,9 @@ export const SurveyAnalysisCTA = ({
|
||||
user,
|
||||
publicDomain,
|
||||
responseCount,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
}: SurveyAnalysisCTAProps) => {
|
||||
const { t } = useTranslate();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -175,6 +182,9 @@ export const SurveyAnalysisCTA = ({
|
||||
setOpen={setOpen}
|
||||
user={user}
|
||||
modalView={modalView}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
/>
|
||||
))}
|
||||
<SuccessMessage environment={environment} survey={survey} />
|
||||
|
||||
@@ -29,6 +29,22 @@ vi.mock("./WebsiteTab", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./personal-links-tab", () => ({
|
||||
PersonalLinksTab: (props: { segments: any[]; surveyId: string; environmentId: string }) => (
|
||||
<div data-testid="personal-links-tab">
|
||||
PersonalLinksTab Content for {props.surveyId} in {props.environmentId}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/upgrade-prompt", () => ({
|
||||
UpgradePrompt: (props: { title: string; description: string; buttons: any[] }) => (
|
||||
<div data-testid="upgrade-prompt">
|
||||
{props.title} - {props.description}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock @tolgee/react
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
@@ -43,6 +59,21 @@ vi.mock("lucide-react", () => ({
|
||||
LinkIcon: () => <div data-testid="link-icon">LinkIcon</div>,
|
||||
GlobeIcon: () => <div data-testid="globe-icon">GlobeIcon</div>,
|
||||
SmartphoneIcon: () => <div data-testid="smartphone-icon">SmartphoneIcon</div>,
|
||||
AlertCircle: ({ className }: { className?: string }) => (
|
||||
<div className={className} data-testid="alert-circle">
|
||||
AlertCircle
|
||||
</div>
|
||||
),
|
||||
AlertTriangle: ({ className }: { className?: string }) => (
|
||||
<div className={className} data-testid="alert-triangle">
|
||||
AlertTriangle
|
||||
</div>
|
||||
),
|
||||
Info: ({ className }: { className?: string }) => (
|
||||
<div className={className} data-testid="info">
|
||||
Info
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockTabs = [
|
||||
@@ -56,7 +87,6 @@ const mockSurveyLink = { id: "survey1", type: "link" };
|
||||
const mockSurveyWeb = { id: "survey2", type: "web" };
|
||||
|
||||
const defaultProps = {
|
||||
handleInitialPageButton: vi.fn(),
|
||||
tabs: mockTabs,
|
||||
activeId: "email",
|
||||
setActiveId: vi.fn(),
|
||||
@@ -67,7 +97,9 @@ const defaultProps = {
|
||||
publicDomain: "http://example.com",
|
||||
setSurveyUrl: vi.fn(),
|
||||
locale: "en" as any,
|
||||
disableBack: false,
|
||||
segments: [],
|
||||
isContactsEnabled: true,
|
||||
isFormbricksCloud: false,
|
||||
};
|
||||
|
||||
describe("EmbedView", () => {
|
||||
@@ -76,11 +108,6 @@ describe("EmbedView", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("does not render back button when disableBack is true", () => {
|
||||
render(<EmbedView {...defaultProps} disableBack={true} />);
|
||||
expect(screen.queryByRole("button", { name: "common.back" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not render desktop tabs for non-link survey type", () => {
|
||||
render(<EmbedView {...defaultProps} survey={mockSurveyWeb} />);
|
||||
// Desktop tabs container should not be present or not have lg:flex if it's a common parent
|
||||
|
||||
@@ -2,33 +2,32 @@
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { AppTab } from "./AppTab";
|
||||
import { EmailTab } from "./EmailTab";
|
||||
import { LinkTab } from "./LinkTab";
|
||||
import { WebsiteTab } from "./WebsiteTab";
|
||||
import { PersonalLinksTab } from "./personal-links-tab";
|
||||
|
||||
interface EmbedViewProps {
|
||||
handleInitialPageButton: () => void;
|
||||
tabs: Array<{ id: string; label: string; icon: any }>;
|
||||
activeId: string;
|
||||
setActiveId: React.Dispatch<React.SetStateAction<string>>;
|
||||
environmentId: string;
|
||||
disableBack: boolean;
|
||||
survey: any;
|
||||
email: string;
|
||||
surveyUrl: string;
|
||||
publicDomain: string;
|
||||
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
|
||||
locale: TUserLocale;
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
export const EmbedView = ({
|
||||
handleInitialPageButton,
|
||||
tabs,
|
||||
disableBack,
|
||||
activeId,
|
||||
setActiveId,
|
||||
environmentId,
|
||||
@@ -38,18 +37,45 @@ export const EmbedView = ({
|
||||
publicDomain,
|
||||
setSurveyUrl,
|
||||
locale,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
}: EmbedViewProps) => {
|
||||
const { t } = useTranslate();
|
||||
const renderActiveTab = () => {
|
||||
switch (activeId) {
|
||||
case "email":
|
||||
return <EmailTab surveyId={survey.id} email={email} />;
|
||||
case "webpage":
|
||||
return <WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />;
|
||||
case "link":
|
||||
return (
|
||||
<LinkTab
|
||||
survey={survey}
|
||||
surveyUrl={surveyUrl}
|
||||
publicDomain={publicDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
case "app":
|
||||
return <AppTab />;
|
||||
case "personal-links":
|
||||
return (
|
||||
<PersonalLinksTab
|
||||
segments={segments}
|
||||
surveyId={survey.id}
|
||||
environmentId={environmentId}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-hidden">
|
||||
{!disableBack && (
|
||||
<div className="border-b border-slate-200 py-2 pl-2">
|
||||
<Button variant="ghost" className="focus:ring-0" onClick={handleInitialPageButton}>
|
||||
<ArrowLeftIcon />
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid h-full grid-cols-4">
|
||||
{survey.type === "link" && (
|
||||
<div className={cn("col-span-1 hidden flex-col gap-3 border-r border-slate-200 p-4 lg:flex")}>
|
||||
@@ -75,21 +101,7 @@ export const EmbedView = ({
|
||||
)}
|
||||
<div
|
||||
className={`col-span-4 h-full overflow-y-auto bg-slate-50 px-4 py-6 ${survey.type === "link" ? "lg:col-span-3" : ""} lg:p-6`}>
|
||||
{activeId === "email" ? (
|
||||
<EmailTab surveyId={survey.id} email={email} />
|
||||
) : activeId === "webpage" ? (
|
||||
<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />
|
||||
) : activeId === "link" ? (
|
||||
<LinkTab
|
||||
survey={survey}
|
||||
surveyUrl={surveyUrl}
|
||||
publicDomain={publicDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={locale}
|
||||
/>
|
||||
) : activeId === "app" ? (
|
||||
<AppTab />
|
||||
) : null}
|
||||
{renderActiveTab()}
|
||||
<div className="mt-2 rounded-md p-3 text-center lg:hidden">
|
||||
{tabs.slice(0, 2).map((tab) => (
|
||||
<Button
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { PanelInfoView } from "./PanelInfoView";
|
||||
|
||||
// Mock next/image
|
||||
vi.mock("next/image", () => ({
|
||||
default: ({ src, alt, className }: { src: any; alt: string; className?: string }) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={src.src} alt={alt} className={className} />
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock next/link
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href, target }: { children: React.ReactNode; href: string; target?: string }) => (
|
||||
<a href={href} target={target}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock Button component
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, variant, asChild }: any) => {
|
||||
if (asChild) {
|
||||
return <div onClick={onClick}>{children}</div>; // NOSONAR
|
||||
}
|
||||
return (
|
||||
<button onClick={onClick} data-variant={variant}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", () => ({
|
||||
ArrowLeftIcon: vi.fn(() => <div data-testid="arrow-left-icon">ArrowLeftIcon</div>),
|
||||
}));
|
||||
|
||||
const mockHandleInitialPageButton = vi.fn();
|
||||
|
||||
describe("PanelInfoView", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders correctly with back button and all sections", async () => {
|
||||
render(<PanelInfoView disableBack={false} handleInitialPageButton={mockHandleInitialPageButton} />);
|
||||
|
||||
// Check for back button
|
||||
const backButton = screen.getByText("common.back");
|
||||
expect(backButton).toBeInTheDocument();
|
||||
expect(screen.getByTestId("arrow-left-icon")).toBeInTheDocument();
|
||||
|
||||
// Check images
|
||||
expect(screen.getAllByAltText("Prolific panel selection UI")[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByAltText("Prolific panel selection UI")[1]).toBeInTheDocument();
|
||||
|
||||
// Check text content (Tolgee keys)
|
||||
expect(screen.getByText("environments.surveys.summary.what_is_a_panel")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.what_is_a_panel_answer")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.when_do_i_need_it")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.when_do_i_need_it_answer")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.what_is_prolific")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.what_is_prolific_answer")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_1")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_1_description")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_2")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_2_description")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_3")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_3_description")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_4")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_4_description")
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Check "Learn more" link
|
||||
const learnMoreLink = screen.getByRole("link", { name: "common.learn_more" });
|
||||
expect(learnMoreLink).toBeInTheDocument();
|
||||
expect(learnMoreLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/market-research-panel"
|
||||
);
|
||||
expect(learnMoreLink).toHaveAttribute("target", "_blank");
|
||||
|
||||
// Click back button
|
||||
await userEvent.click(backButton);
|
||||
expect(mockHandleInitialPageButton).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("renders correctly without back button when disableBack is true", () => {
|
||||
render(<PanelInfoView disableBack={true} handleInitialPageButton={mockHandleInitialPageButton} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "common.back" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("arrow-left-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import ProlificLogo from "@/images/prolific-logo.webp";
|
||||
import ProlificUI from "@/images/prolific-screenshot.webp";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
interface PanelInfoViewProps {
|
||||
disableBack: boolean;
|
||||
handleInitialPageButton: () => void;
|
||||
}
|
||||
|
||||
export const PanelInfoView = ({ disableBack, handleInitialPageButton }: PanelInfoViewProps) => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<div className="h-full overflow-hidden text-slate-900">
|
||||
{!disableBack && (
|
||||
<div className="border-b border-slate-200 py-2">
|
||||
<Button variant="ghost" onClick={handleInitialPageButton}>
|
||||
<ArrowLeftIcon />
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid h-full grid-cols-2">
|
||||
<div className="flex flex-col gap-y-6 border-r border-slate-200 p-8">
|
||||
<Image src={ProlificUI} alt="Prolific panel selection UI" className="rounded-lg shadow-lg" />
|
||||
<div>
|
||||
<p className="text-md font-semibold">{t("environments.surveys.summary.what_is_a_panel")}</p>
|
||||
<p className="text-slate-600">{t("environments.surveys.summary.what_is_a_panel_answer")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-md font-semibold">{t("environments.surveys.summary.when_do_i_need_it")}</p>
|
||||
<p className="text-slate-600">{t("environments.surveys.summary.when_do_i_need_it_answer")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-md font-semibold">{t("environments.surveys.summary.what_is_prolific")}</p>
|
||||
<p className="text-slate-600">{t("environments.surveys.summary.what_is_prolific_answer")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex flex-col gap-y-6 bg-slate-50 p-8">
|
||||
<Image
|
||||
src={ProlificLogo}
|
||||
alt="Prolific panel selection UI"
|
||||
className="absolute right-8 top-8 w-32"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">
|
||||
{t("environments.surveys.summary.how_to_create_a_panel")}
|
||||
</h3>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-md font-semibold">
|
||||
{t("environments.surveys.summary.how_to_create_a_panel_step_1")}
|
||||
</p>
|
||||
<p className="text-slate-600">
|
||||
{t("environments.surveys.summary.how_to_create_a_panel_step_1_description")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-md font-semibold">
|
||||
{t("environments.surveys.summary.how_to_create_a_panel_step_2")}
|
||||
</p>
|
||||
<p className="text-slate-600">
|
||||
{t("environments.surveys.summary.how_to_create_a_panel_step_2_description")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-md font-semibold">
|
||||
{t("environments.surveys.summary.how_to_create_a_panel_step_3")}
|
||||
</p>
|
||||
<p className="text-slate-600">
|
||||
{t("environments.surveys.summary.how_to_create_a_panel_step_3_description")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-md font-semibold">
|
||||
{t("environments.surveys.summary.how_to_create_a_panel_step_4")}
|
||||
</p>
|
||||
<p className="text-slate-600">
|
||||
{t("environments.surveys.summary.how_to_create_a_panel_step_4_description")}
|
||||
</p>
|
||||
</div>
|
||||
<Button className="justify-center" asChild>
|
||||
<Link
|
||||
href="https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/market-research-panel"
|
||||
target="_blank">
|
||||
{t("common.learn_more")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,519 @@
|
||||
import { generatePersonalLinksAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { PersonalLinksTab } from "./personal-links-tab";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions", () => ({
|
||||
generatePersonalLinksAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
loading: vi.fn(),
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock UI components
|
||||
vi.mock("@/modules/ui/components/alert", () => ({
|
||||
Alert: ({ children, variant }: any) => (
|
||||
<div data-testid="alert" data-variant={variant}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
AlertButton: ({ children }: any) => <div data-testid="alert-button">{children}</div>,
|
||||
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
|
||||
AlertTitle: ({ children }: any) => <div data-testid="alert-title">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, disabled, loading, className, ...props }: any) => (
|
||||
<button
|
||||
data-testid="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
data-loading={loading}
|
||||
className={className}
|
||||
{...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/date-picker", () => ({
|
||||
DatePicker: ({ date, updateSurveyDate, minDate, onClearDate }: any) => (
|
||||
<div data-testid="date-picker">
|
||||
<input
|
||||
data-testid="date-input"
|
||||
type="date"
|
||||
value={date ? date.toISOString().split("T")[0] : ""}
|
||||
onChange={(e) => {
|
||||
const newDate = e.target.value ? new Date(e.target.value) : null;
|
||||
updateSurveyDate(newDate);
|
||||
}}
|
||||
/>
|
||||
<button data-testid="clear-date" onClick={() => onClearDate()}>
|
||||
Clear
|
||||
</button>
|
||||
<div data-testid="min-date">{minDate ? minDate.toISOString() : ""}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/select", () => {
|
||||
let globalOnValueChange: ((value: string) => void) | null = null;
|
||||
|
||||
return {
|
||||
Select: ({ children, value, onValueChange, disabled }: any) => {
|
||||
globalOnValueChange = onValueChange;
|
||||
return (
|
||||
<div data-testid="select" data-disabled={disabled} data-value={value}>
|
||||
<div data-testid="select-current-value">{value || "Select option"}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
SelectContent: ({ children }: any) => <div data-testid="select-content">{children}</div>,
|
||||
SelectItem: ({ children, value }: any) => (
|
||||
<div
|
||||
data-testid="select-item"
|
||||
data-value={value}
|
||||
onClick={() => {
|
||||
if (globalOnValueChange) {
|
||||
globalOnValueChange(value);
|
||||
}
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
SelectTrigger: ({ children, className }: any) => (
|
||||
<div data-testid="select-trigger" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
SelectValue: ({ placeholder }: any) => <div data-testid="select-value">{placeholder}</div>,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
DownloadIcon: () => <div data-testid="download-icon" />,
|
||||
KeyIcon: () => <div data-testid="key-icon" />,
|
||||
}));
|
||||
|
||||
// Mock Next.js Link
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href, target, rel }: any) => (
|
||||
<a data-testid="link" href={href} target={target} rel={rel}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockGeneratePersonalLinksAction = vi.mocked(generatePersonalLinksAction);
|
||||
const mockToast = vi.mocked(toast);
|
||||
const mockGetFormattedErrorMessage = vi.mocked(getFormattedErrorMessage);
|
||||
|
||||
// Mock segments data
|
||||
const mockSegments = [
|
||||
{
|
||||
id: "segment1",
|
||||
title: "Public Segment 1",
|
||||
isPrivate: false,
|
||||
description: "Test segment 1",
|
||||
filters: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
surveys: [],
|
||||
},
|
||||
{
|
||||
id: "segment2",
|
||||
title: "Public Segment 2",
|
||||
isPrivate: false,
|
||||
description: "Test segment 2",
|
||||
filters: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
surveys: [],
|
||||
},
|
||||
{
|
||||
id: "segment3",
|
||||
title: "Private Segment",
|
||||
isPrivate: true,
|
||||
description: "Test private segment",
|
||||
filters: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
surveys: [],
|
||||
},
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
environmentId: "test-env-id",
|
||||
surveyId: "test-survey-id",
|
||||
segments: mockSegments,
|
||||
isContactsEnabled: true,
|
||||
isFormbricksCloud: false,
|
||||
};
|
||||
|
||||
// Helper function to trigger select change
|
||||
const selectOption = (value: string) => {
|
||||
const selectItems = screen.getAllByTestId("select-item");
|
||||
const targetItem = selectItems.find((item) => item.getAttribute("data-value") === value);
|
||||
if (targetItem) {
|
||||
fireEvent.click(targetItem);
|
||||
}
|
||||
};
|
||||
|
||||
describe("PersonalLinksTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders the component with correct title and description", () => {
|
||||
render(<PersonalLinksTab {...defaultProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.generate_personal_links_title")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.generate_personal_links_description")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders recipients section with segment selection", () => {
|
||||
render(<PersonalLinksTab {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("common.recipients")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("select")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.create_and_manage_segments")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders expiry date section with date picker", () => {
|
||||
render(<PersonalLinksTab {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("environments.surveys.summary.expiry_date_optional")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("date-picker")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.expiry_date_description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders generate button with correct initial state", () => {
|
||||
render(<PersonalLinksTab {...defaultProps} />);
|
||||
|
||||
const button = screen.getByTestId("button");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toBeDisabled();
|
||||
expect(screen.getByText("environments.surveys.summary.generate_and_download_links")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("download-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders info alert with correct content", () => {
|
||||
render(<PersonalLinksTab {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("alert")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.personal_links_work_with_segments")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("link")).toHaveAttribute(
|
||||
"href",
|
||||
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting#segment-configuration"
|
||||
);
|
||||
});
|
||||
|
||||
test("filters out private segments and shows only public segments", () => {
|
||||
render(<PersonalLinksTab {...defaultProps} />);
|
||||
|
||||
const selectItems = screen.getAllByTestId("select-item");
|
||||
expect(selectItems).toHaveLength(2); // Only public segments
|
||||
expect(selectItems[0]).toHaveTextContent("Public Segment 1");
|
||||
expect(selectItems[1]).toHaveTextContent("Public Segment 2");
|
||||
});
|
||||
|
||||
test("shows no segments message when no public segments available", () => {
|
||||
const propsWithPrivateSegments = {
|
||||
...defaultProps,
|
||||
segments: [mockSegments[2]], // Only private segment
|
||||
};
|
||||
|
||||
render(<PersonalLinksTab {...propsWithPrivateSegments} />);
|
||||
|
||||
expect(screen.getByText("environments.surveys.summary.no_segments_available")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("select")).toHaveAttribute("data-disabled", "true");
|
||||
expect(screen.getByTestId("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("enables button when segment is selected", () => {
|
||||
render(<PersonalLinksTab {...defaultProps} />);
|
||||
|
||||
// Initially disabled
|
||||
expect(screen.getByTestId("button")).toBeDisabled();
|
||||
|
||||
// Select a segment
|
||||
selectOption("segment1");
|
||||
|
||||
// Should now be enabled
|
||||
expect(screen.getByTestId("button")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test("handles date selection correctly", () => {
|
||||
render(<PersonalLinksTab {...defaultProps} />);
|
||||
|
||||
const dateInput = screen.getByTestId("date-input");
|
||||
const testDate = "2024-12-31";
|
||||
|
||||
fireEvent.change(dateInput, { target: { value: testDate } });
|
||||
|
||||
expect(dateInput).toHaveValue(testDate);
|
||||
});
|
||||
|
||||
test("clears date when clear button is clicked", () => {
|
||||
render(<PersonalLinksTab {...defaultProps} />);
|
||||
|
||||
const dateInput = screen.getByTestId("date-input");
|
||||
const clearButton = screen.getByTestId("clear-date");
|
||||
|
||||
// Set a date first
|
||||
fireEvent.change(dateInput, { target: { value: "2024-12-31" } });
|
||||
|
||||
// Clear the date
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
expect(dateInput).toHaveValue("");
|
||||
});
|
||||
|
||||
test("sets minimum date to tomorrow", () => {
|
||||
render(<PersonalLinksTab {...defaultProps} />);
|
||||
|
||||
const minDateElement = screen.getByTestId("min-date");
|
||||
// Should have some ISO date string for a future date
|
||||
expect(minDateElement.textContent).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
test("successfully generates and downloads links", async () => {
|
||||
const mockResult = {
|
||||
data: {
|
||||
downloadUrl: "https://example.com/download/file.csv",
|
||||
fileName: "personal-links.csv",
|
||||
count: 5,
|
||||
},
|
||||
};
|
||||
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
|
||||
|
||||
render(<PersonalLinksTab {...defaultProps} />);
|
||||
|
||||
// Select a segment
|
||||
selectOption("segment1");
|
||||
|
||||
// Click generate button
|
||||
const generateButton = screen.getByTestId("button");
|
||||
fireEvent.click(generateButton);
|
||||
|
||||
// Verify action was called
|
||||
await waitFor(() => {
|
||||
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
|
||||
surveyId: "test-survey-id",
|
||||
segmentId: "segment1",
|
||||
environmentId: "test-env-id",
|
||||
expirationDays: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
// Verify loading toast
|
||||
expect(mockToast.loading).toHaveBeenCalledWith("environments.surveys.summary.generating_links_toast", {
|
||||
duration: 5000,
|
||||
id: "generating-links",
|
||||
});
|
||||
});
|
||||
|
||||
test("generates links with expiry date when date is selected", async () => {
|
||||
const mockResult = {
|
||||
data: {
|
||||
downloadUrl: "https://example.com/download/file.csv",
|
||||
fileName: "personal-links.csv",
|
||||
count: 3,
|
||||
},
|
||||
};
|
||||
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
|
||||
|
||||
render(<PersonalLinksTab {...defaultProps} />);
|
||||
|
||||
// Select a segment
|
||||
selectOption("segment1");
|
||||
|
||||
// Set expiry date (10 days from now)
|
||||
const dateInput = screen.getByTestId("date-input");
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 10);
|
||||
const expiryDate = futureDate.toISOString().split("T")[0];
|
||||
fireEvent.change(dateInput, { target: { value: expiryDate } });
|
||||
|
||||
// Click generate button
|
||||
const generateButton = screen.getByTestId("button");
|
||||
fireEvent.click(generateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
|
||||
surveyId: "test-survey-id",
|
||||
segmentId: "segment1",
|
||||
environmentId: "test-env-id",
|
||||
expirationDays: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
// Verify that expirationDays is a reasonable value (between 9-10 days)
|
||||
const callArgs = mockGeneratePersonalLinksAction.mock.calls[0][0];
|
||||
expect(callArgs.expirationDays).toBeGreaterThanOrEqual(9);
|
||||
expect(callArgs.expirationDays).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
test("handles error response from generatePersonalLinksAction", async () => {
|
||||
const mockErrorResult = {
|
||||
serverError: "Test error message",
|
||||
};
|
||||
mockGeneratePersonalLinksAction.mockResolvedValue(mockErrorResult);
|
||||
mockGetFormattedErrorMessage.mockReturnValue("Formatted error message");
|
||||
|
||||
render(<PersonalLinksTab {...defaultProps} />);
|
||||
|
||||
// Select a segment
|
||||
selectOption("segment1");
|
||||
|
||||
// Click generate button
|
||||
const generateButton = screen.getByTestId("button");
|
||||
fireEvent.click(generateButton);
|
||||
|
||||
// Wait for the action to be called
|
||||
await waitFor(() => {
|
||||
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
|
||||
surveyId: "test-survey-id",
|
||||
segmentId: "segment1",
|
||||
environmentId: "test-env-id",
|
||||
expirationDays: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
// Wait for error handling
|
||||
await waitFor(() => {
|
||||
expect(mockGetFormattedErrorMessage).toHaveBeenCalledWith(mockErrorResult);
|
||||
expect(mockToast.error).toHaveBeenCalledWith("Formatted error message", {
|
||||
duration: 5000,
|
||||
id: "generating-links",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("shows generating state when triggered", async () => {
|
||||
// Mock a promise that resolves quickly
|
||||
const mockResult = { data: { downloadUrl: "test", fileName: "test.csv", count: 1 } };
|
||||
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
|
||||
|
||||
render(<PersonalLinksTab {...defaultProps} />);
|
||||
|
||||
// Select a segment
|
||||
selectOption("segment1");
|
||||
|
||||
// Click generate button
|
||||
const generateButton = screen.getByTestId("button");
|
||||
fireEvent.click(generateButton);
|
||||
|
||||
// Verify loading toast is called
|
||||
expect(mockToast.loading).toHaveBeenCalledWith("environments.surveys.summary.generating_links_toast", {
|
||||
duration: 5000,
|
||||
id: "generating-links",
|
||||
});
|
||||
});
|
||||
|
||||
test("button is disabled when no segment is selected", () => {
|
||||
render(<PersonalLinksTab {...defaultProps} />);
|
||||
|
||||
const generateButton = screen.getByTestId("button");
|
||||
expect(generateButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("button is disabled when no public segments are available", () => {
|
||||
const propsWithNoPublicSegments = {
|
||||
...defaultProps,
|
||||
segments: [mockSegments[2]], // Only private segments
|
||||
};
|
||||
|
||||
render(<PersonalLinksTab {...propsWithNoPublicSegments} />);
|
||||
|
||||
const generateButton = screen.getByTestId("button");
|
||||
expect(generateButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("handles empty segments array", () => {
|
||||
const propsWithEmptySegments = {
|
||||
...defaultProps,
|
||||
segments: [],
|
||||
};
|
||||
|
||||
render(<PersonalLinksTab {...propsWithEmptySegments} />);
|
||||
|
||||
expect(screen.getByText("environments.surveys.summary.no_segments_available")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("calculates expiration days correctly for different dates", async () => {
|
||||
const mockResult = {
|
||||
data: {
|
||||
downloadUrl: "https://example.com/download/file.csv",
|
||||
fileName: "test.csv",
|
||||
count: 1,
|
||||
},
|
||||
};
|
||||
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
|
||||
|
||||
render(<PersonalLinksTab {...defaultProps} />);
|
||||
|
||||
// Select a segment
|
||||
selectOption("segment1");
|
||||
|
||||
// Set expiry date to 5 days from now
|
||||
const dateInput = screen.getByTestId("date-input");
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 5);
|
||||
const expiryDate = futureDate.toISOString().split("T")[0];
|
||||
fireEvent.change(dateInput, { target: { value: expiryDate } });
|
||||
|
||||
// Click generate button
|
||||
const generateButton = screen.getByTestId("button");
|
||||
fireEvent.click(generateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
|
||||
surveyId: "test-survey-id",
|
||||
segmentId: "segment1",
|
||||
environmentId: "test-env-id",
|
||||
expirationDays: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
// Verify that expirationDays is a reasonable value (between 4-5 days)
|
||||
const callArgs = mockGeneratePersonalLinksAction.mock.calls[0][0];
|
||||
expect(callArgs.expirationDays).toBeGreaterThanOrEqual(4);
|
||||
expect(callArgs.expirationDays).toBeLessThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DatePicker } from "@/modules/ui/components/date-picker";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { generatePersonalLinksAction } from "../../actions";
|
||||
|
||||
interface PersonalLinksTabProps {
|
||||
environmentId: string;
|
||||
surveyId: string;
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
// Custom DatePicker component with date restrictions
|
||||
const RestrictedDatePicker = ({
|
||||
date,
|
||||
updateSurveyDate,
|
||||
}: {
|
||||
date: Date | null;
|
||||
updateSurveyDate: (date: Date | null) => void;
|
||||
}) => {
|
||||
// Get tomorrow's date
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(0, 0, 0, 0);
|
||||
|
||||
const handleDateUpdate = (date: Date) => {
|
||||
updateSurveyDate(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<DatePicker
|
||||
date={date}
|
||||
updateSurveyDate={handleDateUpdate}
|
||||
minDate={tomorrow}
|
||||
onClearDate={() => updateSurveyDate(null)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const PersonalLinksTab = ({
|
||||
environmentId,
|
||||
segments,
|
||||
surveyId,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
}: PersonalLinksTabProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [selectedSegment, setSelectedSegment] = useState<string>("");
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const publicSegments = segments.filter((segment) => !segment.isPrivate);
|
||||
|
||||
// Utility function for file downloads
|
||||
const downloadFile = (url: string, filename: string) => {
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const handleGenerateLinks = async () => {
|
||||
if (!selectedSegment || isGenerating) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
|
||||
// Show initial toast
|
||||
toast.loading(t("environments.surveys.summary.generating_links_toast"), {
|
||||
duration: 5000,
|
||||
id: "generating-links",
|
||||
});
|
||||
|
||||
const result = await generatePersonalLinksAction({
|
||||
surveyId: surveyId,
|
||||
segmentId: selectedSegment,
|
||||
environmentId: environmentId,
|
||||
expirationDays: expiryDate
|
||||
? Math.max(1, Math.floor((expiryDate.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)))
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
downloadFile(result.data.downloadUrl, result.data.fileName || "personal-links.csv");
|
||||
toast.success(t("environments.surveys.summary.links_generated_success_toast"), {
|
||||
duration: 5000,
|
||||
id: "generating-links",
|
||||
});
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage, {
|
||||
duration: 5000,
|
||||
id: "generating-links",
|
||||
});
|
||||
}
|
||||
setIsGenerating(false);
|
||||
};
|
||||
|
||||
// Button state logic
|
||||
const isButtonDisabled = !selectedSegment || isGenerating || publicSegments.length === 0;
|
||||
const buttonText = isGenerating
|
||||
? t("environments.surveys.summary.generating_links")
|
||||
: t("environments.surveys.summary.generate_and_download_links");
|
||||
|
||||
if (!isContactsEnabled) {
|
||||
return (
|
||||
<UpgradePrompt
|
||||
title={t("environments.surveys.summary.personal_links_upgrade_prompt_title")}
|
||||
description={t("environments.surveys.summary.personal_links_upgrade_prompt_description")}
|
||||
buttons={[
|
||||
{
|
||||
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
|
||||
href: isFormbricksCloud
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
href: isFormbricksCloud
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/learn-more-self-hosting-license",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full grow flex-col gap-6">
|
||||
<div>
|
||||
<h2 className="mb-2 text-lg font-semibold text-slate-800">
|
||||
{t("environments.surveys.summary.generate_personal_links_title")}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600">
|
||||
{t("environments.surveys.summary.generate_personal_links_description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Recipients Section */}
|
||||
<div>
|
||||
<label htmlFor="segment-select" className="mb-2 block text-sm font-medium text-slate-700">
|
||||
{t("common.recipients")}
|
||||
</label>
|
||||
<Select
|
||||
value={selectedSegment}
|
||||
onValueChange={setSelectedSegment}
|
||||
disabled={publicSegments.length === 0}>
|
||||
<SelectTrigger id="segment-select" className="w-full bg-white">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
publicSegments.length === 0
|
||||
? t("environments.surveys.summary.no_segments_available")
|
||||
: t("environments.surveys.summary.select_segment")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{publicSegments.map((segment) => (
|
||||
<SelectItem key={segment.id} value={segment.id}>
|
||||
{segment.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{t("environments.surveys.summary.create_and_manage_segments")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Expiry Date Section */}
|
||||
<div>
|
||||
<label htmlFor="expiry-date-picker" className="mb-2 block text-sm font-medium text-slate-700">
|
||||
{t("environments.surveys.summary.expiry_date_optional")}
|
||||
</label>
|
||||
<div id="expiry-date-picker">
|
||||
<RestrictedDatePicker
|
||||
date={expiryDate}
|
||||
updateSurveyDate={(date: Date | null) => setExpiryDate(date)}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{t("environments.surveys.summary.expiry_date_description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Generate Button */}
|
||||
<Button
|
||||
onClick={handleGenerateLinks}
|
||||
disabled={isButtonDisabled}
|
||||
loading={isGenerating}
|
||||
className="w-fit">
|
||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
{/* Info Box */}
|
||||
<Alert variant="info" size="small">
|
||||
<AlertTitle>{t("environments.surveys.summary.personal_links_work_with_segments")}</AlertTitle>
|
||||
<AlertButton>
|
||||
<Link
|
||||
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting#segment-configuration"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
{t("common.learn_more")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,10 +2,12 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
||||
import { DEFAULT_LOCALE } from "@/lib/constants";
|
||||
import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -36,6 +38,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
|
||||
|
||||
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
|
||||
const initialSurveySummary = await getSurveySummary(surveyId);
|
||||
@@ -54,6 +58,9 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" />
|
||||
|
||||
@@ -108,6 +108,10 @@
|
||||
"thanks_for_upgrading": "Vielen Dank, dass Du dein Formbricks-Abonnement aktualisiert hast.",
|
||||
"upgrade_successful": "Upgrade erfolgreich"
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Dein Link ist abgelaufen.",
|
||||
"link_expired_description": "Der von dir verwendete Link ist nicht mehr gültig."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Akzeptiert",
|
||||
"account": "Konto",
|
||||
@@ -313,6 +317,7 @@
|
||||
"question_id": "Frage-ID",
|
||||
"questions": "Fragen",
|
||||
"read_docs": "Dokumentation lesen",
|
||||
"recipients": "Empfänger",
|
||||
"remove": "Entfernen",
|
||||
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
|
||||
"report_survey": "Umfrage melden",
|
||||
@@ -1709,6 +1714,7 @@
|
||||
"congrats": "Glückwunsch! Deine Umfrage ist jetzt live.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Verbinde deine Website oder App mit Formbricks, um loszulegen.",
|
||||
"copy_link_to_public_results": "Link zu öffentlichen Ergebnissen kopieren",
|
||||
"create_and_manage_segments": "Erstellen und verwalten Sie Ihre Segmente unter Kontakte > Segmente",
|
||||
"create_single_use_links": "Single-Use Links erstellen",
|
||||
"create_single_use_links_description": "Akzeptiere nur eine Antwort pro Link. So geht's.",
|
||||
"custom_range": "Benutzerdefinierter Bereich...",
|
||||
@@ -1727,12 +1733,19 @@
|
||||
"embed_on_website": "Auf Website einbetten",
|
||||
"embed_pop_up_survey_title": "Wie man eine Pop-up-Umfrage auf seiner Website einbindet",
|
||||
"embed_survey": "Umfrage einbetten",
|
||||
"expiry_date_description": "Sobald der Link abläuft, kann der Empfänger nicht mehr auf die Umfrage antworten.",
|
||||
"expiry_date_optional": "Ablaufdatum (optional)",
|
||||
"failed_to_copy_link": "Kopieren des Links fehlgeschlagen",
|
||||
"filter_added_successfully": "Filter erfolgreich hinzugefügt",
|
||||
"filter_updated_successfully": "Filter erfolgreich aktualisiert",
|
||||
"filtered_responses_csv": "Gefilterte Antworten (CSV)",
|
||||
"filtered_responses_excel": "Gefilterte Antworten (Excel)",
|
||||
"formbricks_email_survey_preview": "Formbricks E-Mail-Umfrage Vorschau",
|
||||
"generate_and_download_links": "Links generieren und herunterladen",
|
||||
"generate_personal_links_description": "Erstellen Sie persönliche Links für ein Segment und ordnen Sie Umfrageantworten jedem Kontakt zu. Eine CSV-Datei Ihrer persönlichen Links inklusive relevanter Kontaktinformationen wird automatisch heruntergeladen.",
|
||||
"generate_personal_links_title": "Maximieren Sie Erkenntnisse mit persönlichen Umfragelinks",
|
||||
"generating_links": "Links werden generiert",
|
||||
"generating_links_toast": "Links werden generiert, der Download startet in Kürze…",
|
||||
"go_to_setup_checklist": "Gehe zur Einrichtungs-Checkliste \uD83D\uDC49",
|
||||
"hide_embed_code": "Einbettungscode ausblenden",
|
||||
"how_to_create_a_panel": "Wie man ein Panel erstellt",
|
||||
@@ -1758,12 +1771,18 @@
|
||||
"last_quarter": "Letztes Quartal",
|
||||
"last_year": "Letztes Jahr",
|
||||
"link_to_public_results_copied": "Link zu öffentlichen Ergebnissen kopiert",
|
||||
"links_generated_success_toast": "Links erfolgreich generiert, Ihr Download beginnt in Kürze.",
|
||||
"make_sure_the_survey_type_is_set_to": "Stelle sicher, dass der Umfragetyp richtig eingestellt ist",
|
||||
"mobile_app": "Mobile App",
|
||||
"no_responses_found": "Keine Antworten gefunden",
|
||||
"no_segments_available": "Keine Segmente verfügbar",
|
||||
"only_completed": "Nur vollständige Antworten",
|
||||
"other_values_found": "Andere Werte gefunden",
|
||||
"overall": "Insgesamt",
|
||||
"personal_links": "Persönliche Links",
|
||||
"personal_links_upgrade_prompt_description": "Erstellen Sie persönliche Links für ein Segment und verknüpfen Sie Umfrageantworten mit jedem Kontakt.",
|
||||
"personal_links_upgrade_prompt_title": "Verwende persönliche Links mit einem höheren Plan",
|
||||
"personal_links_work_with_segments": "Persönliche Links funktionieren mit Segmenten.",
|
||||
"publish_to_web": "Im Web veröffentlichen",
|
||||
"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.",
|
||||
@@ -1772,6 +1791,7 @@
|
||||
"quickstart_web_apps": "Schnellstart: Web-Apps",
|
||||
"quickstart_web_apps_description": "Bitte folge der Schnellstartanleitung, um loszulegen:",
|
||||
"results_are_public": "Ergebnisse sind öffentlich",
|
||||
"select_segment": "Segment auswählen",
|
||||
"selected_responses_csv": "Ausgewählte Antworten (CSV)",
|
||||
"selected_responses_excel": "Ausgewählte Antworten (Excel)",
|
||||
"send_preview": "Vorschau senden",
|
||||
@@ -1797,6 +1817,7 @@
|
||||
"this_year": "Dieses Jahr",
|
||||
"time_to_complete": "Zeit zur Fertigstellung",
|
||||
"to_connect_your_website_with_formbricks": "deine Website mit Formbricks zu verbinden",
|
||||
"to_create_personal_links_segment_required": "Um persönliche Links für Ihre Umfrage zu erstellen, müssen Sie zuerst ein Segment einrichten.",
|
||||
"ttc_tooltip": "Durchschnittliche Zeit bis zum Abschluss der Umfrage.",
|
||||
"unknown_question_type": "Unbekannter Fragetyp",
|
||||
"unpublish_from_web": "Aus dem Web entfernen",
|
||||
|
||||
@@ -108,6 +108,10 @@
|
||||
"thanks_for_upgrading": "Thanks a lot for upgrading your Formbricks subscription.",
|
||||
"upgrade_successful": "Upgrade successful"
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Your link is expired.",
|
||||
"link_expired_description": "The link you used is no longer valid."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Accepted",
|
||||
"account": "Account",
|
||||
@@ -313,6 +317,7 @@
|
||||
"question_id": "Question ID",
|
||||
"questions": "Questions",
|
||||
"read_docs": "Read Docs",
|
||||
"recipients": "Recipients",
|
||||
"remove": "Remove",
|
||||
"reorder_and_hide_columns": "Reorder and hide columns",
|
||||
"report_survey": "Report Survey",
|
||||
@@ -1709,6 +1714,7 @@
|
||||
"congrats": "Congrats! Your survey is live.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Connect your website or app with Formbricks to get started.",
|
||||
"copy_link_to_public_results": "Copy link to public results",
|
||||
"create_and_manage_segments": "Create and manage your Segments under Contacts > Segments",
|
||||
"create_single_use_links": "Create single-use links",
|
||||
"create_single_use_links_description": "Accept only one submission per link. Here is how.",
|
||||
"custom_range": "Custom range...",
|
||||
@@ -1727,12 +1733,19 @@
|
||||
"embed_on_website": "Embed on website",
|
||||
"embed_pop_up_survey_title": "How to embed a pop-up survey on your website",
|
||||
"embed_survey": "Embed survey",
|
||||
"expiry_date_description": "Once the link expires, the recipient cannot respond to survey any longer.",
|
||||
"expiry_date_optional": "Expiry date (optional)",
|
||||
"failed_to_copy_link": "Failed to copy link",
|
||||
"filter_added_successfully": "Filter added successfully",
|
||||
"filter_updated_successfully": "Filter updated successfully",
|
||||
"filtered_responses_csv": "Filtered responses (CSV)",
|
||||
"filtered_responses_excel": "Filtered responses (Excel)",
|
||||
"formbricks_email_survey_preview": "Formbricks Email Survey Preview",
|
||||
"generate_and_download_links": "Generate & download links",
|
||||
"generate_personal_links_description": "Generate personal links for a segment and match survey responses to each contact. A CSV of you personal links incl. relevant contact information will be downloaded automatically.",
|
||||
"generate_personal_links_title": "Maximize insights with personal survey links",
|
||||
"generating_links": "Generating links",
|
||||
"generating_links_toast": "Generating links, download will start soon…",
|
||||
"go_to_setup_checklist": "Go to Setup Checklist \uD83D\uDC49",
|
||||
"hide_embed_code": "Hide embed code",
|
||||
"how_to_create_a_panel": "How to create a panel",
|
||||
@@ -1758,12 +1771,18 @@
|
||||
"last_quarter": "Last quarter",
|
||||
"last_year": "Last year",
|
||||
"link_to_public_results_copied": "Link to public results copied",
|
||||
"links_generated_success_toast": "Links generated successfully, your download will start soon.",
|
||||
"make_sure_the_survey_type_is_set_to": "Make sure the survey type is set to",
|
||||
"mobile_app": "Mobile app",
|
||||
"no_responses_found": "No responses found",
|
||||
"no_segments_available": "No segments available",
|
||||
"only_completed": "Only completed",
|
||||
"other_values_found": "Other values found",
|
||||
"overall": "Overall",
|
||||
"personal_links": "Personal links",
|
||||
"personal_links_upgrade_prompt_description": "Generate personal links for a segment and link survey responses to each contact.",
|
||||
"personal_links_upgrade_prompt_title": "Use personal links with a higher plan",
|
||||
"personal_links_work_with_segments": "Personal links work with segments.",
|
||||
"publish_to_web": "Publish to web",
|
||||
"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.",
|
||||
@@ -1772,6 +1791,7 @@
|
||||
"quickstart_web_apps": "Quickstart: Web apps",
|
||||
"quickstart_web_apps_description": "Please follow the Quickstart guide to get started:",
|
||||
"results_are_public": "Results are public",
|
||||
"select_segment": "Select segment",
|
||||
"selected_responses_csv": "Selected responses (CSV)",
|
||||
"selected_responses_excel": "Selected responses (Excel)",
|
||||
"send_preview": "Send preview",
|
||||
@@ -1797,6 +1817,7 @@
|
||||
"this_year": "This year",
|
||||
"time_to_complete": "Time to Complete",
|
||||
"to_connect_your_website_with_formbricks": "to connect your website with Formbricks",
|
||||
"to_create_personal_links_segment_required": "To create personal links for your survey, you need to set up a segment first.",
|
||||
"ttc_tooltip": "Average time to complete the survey.",
|
||||
"unknown_question_type": "Unknown Question Type",
|
||||
"unpublish_from_web": "Unpublish from web",
|
||||
|
||||
@@ -108,6 +108,10 @@
|
||||
"thanks_for_upgrading": "Merci beaucoup d'avoir mis à niveau votre abonnement Formbricks.",
|
||||
"upgrade_successful": "Mise à niveau réussie"
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Votre lien est expiré.",
|
||||
"link_expired_description": "Le lien que vous avez utilisé n'est plus valide."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Accepté",
|
||||
"account": "Compte",
|
||||
@@ -313,6 +317,7 @@
|
||||
"question_id": "ID de la question",
|
||||
"questions": "Questions",
|
||||
"read_docs": "Lire les documents",
|
||||
"recipients": "Destinataires",
|
||||
"remove": "Retirer",
|
||||
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
|
||||
"report_survey": "Rapport d'enquête",
|
||||
@@ -1709,6 +1714,7 @@
|
||||
"congrats": "Félicitations ! Votre enquête est en ligne.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Connectez votre site web ou votre application à Formbricks pour commencer.",
|
||||
"copy_link_to_public_results": "Copier le lien vers les résultats publics",
|
||||
"create_and_manage_segments": "Créez et gérez vos Segments sous Contacts > Segments",
|
||||
"create_single_use_links": "Créer des liens à usage unique",
|
||||
"create_single_use_links_description": "Acceptez uniquement une soumission par lien. Voici comment.",
|
||||
"custom_range": "Plage personnalisée...",
|
||||
@@ -1727,12 +1733,19 @@
|
||||
"embed_on_website": "Incorporer sur le site web",
|
||||
"embed_pop_up_survey_title": "Comment intégrer une enquête pop-up sur votre site web",
|
||||
"embed_survey": "Intégrer l'enquête",
|
||||
"expiry_date_description": "Une fois le lien expiré, le destinataire ne peut plus répondre au sondage.",
|
||||
"expiry_date_optional": "Date d'expiration (facultatif)",
|
||||
"failed_to_copy_link": "Échec de la copie du lien",
|
||||
"filter_added_successfully": "Filtre ajouté avec succès",
|
||||
"filter_updated_successfully": "Filtre mis à jour avec succès",
|
||||
"filtered_responses_csv": "Réponses filtrées (CSV)",
|
||||
"filtered_responses_excel": "Réponses filtrées (Excel)",
|
||||
"formbricks_email_survey_preview": "Aperçu de l'enquête par e-mail Formbricks",
|
||||
"generate_and_download_links": "Générer et télécharger les liens",
|
||||
"generate_personal_links_description": "Générez des liens personnels pour un segment et associez les réponses du sondage à chaque contact. Un fichier CSV de vos liens personnels incluant les informations de contact pertinentes sera téléchargé automatiquement.",
|
||||
"generate_personal_links_title": "Maximisez les insights avec des liens d'enquête personnels",
|
||||
"generating_links": "Génération de liens",
|
||||
"generating_links_toast": "Génération des liens, le téléchargement commencera bientôt…",
|
||||
"go_to_setup_checklist": "Allez à la liste de contrôle de configuration \uD83D\uDC49",
|
||||
"hide_embed_code": "Cacher le code d'intégration",
|
||||
"how_to_create_a_panel": "Comment créer un panneau",
|
||||
@@ -1758,12 +1771,18 @@
|
||||
"last_quarter": "dernier trimestre",
|
||||
"last_year": "l'année dernière",
|
||||
"link_to_public_results_copied": "Lien vers les résultats publics copié",
|
||||
"links_generated_success_toast": "Liens générés avec succès, votre téléchargement commencera bientôt.",
|
||||
"make_sure_the_survey_type_is_set_to": "Assurez-vous que le type d'enquête est défini sur",
|
||||
"mobile_app": "Application mobile",
|
||||
"no_responses_found": "Aucune réponse trouvée",
|
||||
"no_segments_available": "Aucun segment disponible",
|
||||
"only_completed": "Uniquement terminé",
|
||||
"other_values_found": "D'autres valeurs trouvées",
|
||||
"overall": "Globalement",
|
||||
"personal_links": "Liens personnels",
|
||||
"personal_links_upgrade_prompt_description": "Générez des liens personnels pour un segment et associez les réponses du sondage à chaque contact.",
|
||||
"personal_links_upgrade_prompt_title": "Utilisez des liens personnels avec un plan supérieur",
|
||||
"personal_links_work_with_segments": "Les liens personnels fonctionnent avec les segments.",
|
||||
"publish_to_web": "Publier sur le web",
|
||||
"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.",
|
||||
@@ -1772,6 +1791,7 @@
|
||||
"quickstart_web_apps": "Démarrage rapide : Applications web",
|
||||
"quickstart_web_apps_description": "Veuillez suivre le guide de démarrage rapide pour commencer :",
|
||||
"results_are_public": "Les résultats sont publics.",
|
||||
"select_segment": "Sélectionner le segment",
|
||||
"selected_responses_csv": "Réponses sélectionnées (CSV)",
|
||||
"selected_responses_excel": "Réponses sélectionnées (Excel)",
|
||||
"send_preview": "Envoyer un aperçu",
|
||||
@@ -1797,6 +1817,7 @@
|
||||
"this_year": "Cette année",
|
||||
"time_to_complete": "Temps à compléter",
|
||||
"to_connect_your_website_with_formbricks": "connecter votre site web à Formbricks",
|
||||
"to_create_personal_links_segment_required": "Pour créer des liens personnels pour votre enquête, vous devez d'abord définir un segment.",
|
||||
"ttc_tooltip": "Temps moyen pour compléter l'enquête.",
|
||||
"unknown_question_type": "Type de question inconnu",
|
||||
"unpublish_from_web": "Désactiver la publication sur le web",
|
||||
|
||||
@@ -108,6 +108,10 @@
|
||||
"thanks_for_upgrading": "Valeu demais por atualizar sua assinatura do Formbricks.",
|
||||
"upgrade_successful": "Atualização bem-sucedida"
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Seu link está expirado.",
|
||||
"link_expired_description": "O link que você usou não é mais válido."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Aceito",
|
||||
"account": "conta",
|
||||
@@ -313,6 +317,7 @@
|
||||
"question_id": "ID da Pergunta",
|
||||
"questions": "Perguntas",
|
||||
"read_docs": "Ler Documentos",
|
||||
"recipients": "Destinatários",
|
||||
"remove": "remover",
|
||||
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
|
||||
"report_survey": "Relatório de Pesquisa",
|
||||
@@ -1709,6 +1714,7 @@
|
||||
"congrats": "Parabéns! Sua pesquisa está no ar.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Conecte seu site ou app com o Formbricks para começar.",
|
||||
"copy_link_to_public_results": "Copiar link para resultados públicos",
|
||||
"create_and_manage_segments": "Crie e gerencie seus Segmentos em Contatos > Segmentos",
|
||||
"create_single_use_links": "Crie links de uso único",
|
||||
"create_single_use_links_description": "Aceite apenas uma submissão por link. Aqui está como.",
|
||||
"custom_range": "Intervalo personalizado...",
|
||||
@@ -1727,12 +1733,19 @@
|
||||
"embed_on_website": "Incorporar no site",
|
||||
"embed_pop_up_survey_title": "Como incorporar uma pesquisa pop-up no seu site",
|
||||
"embed_survey": "Incorporar pesquisa",
|
||||
"expiry_date_description": "Quando o link expirar, o destinatário não poderá mais responder à pesquisa.",
|
||||
"expiry_date_optional": "Data de expiração (opcional)",
|
||||
"failed_to_copy_link": "Falha ao copiar link",
|
||||
"filter_added_successfully": "Filtro adicionado com sucesso",
|
||||
"filter_updated_successfully": "Filtro atualizado com sucesso",
|
||||
"filtered_responses_csv": "Respostas filtradas (CSV)",
|
||||
"filtered_responses_excel": "Respostas filtradas (Excel)",
|
||||
"formbricks_email_survey_preview": "Prévia da Pesquisa por E-mail do Formbricks",
|
||||
"generate_and_download_links": "Gerar & baixar links",
|
||||
"generate_personal_links_description": "Gerar links pessoais para um segmento e associar respostas de pesquisa a cada contato. Um CSV dos seus links pessoais com as informações de contato relevantes será baixado automaticamente.",
|
||||
"generate_personal_links_title": "Maximize insights com links de pesquisa personalizados",
|
||||
"generating_links": "Gerando links",
|
||||
"generating_links_toast": "Gerando links, o download começará em breve…",
|
||||
"go_to_setup_checklist": "Vai para a Lista de Configuração \uD83D\uDC49",
|
||||
"hide_embed_code": "Esconder código de incorporação",
|
||||
"how_to_create_a_panel": "Como criar um painel",
|
||||
@@ -1758,12 +1771,18 @@
|
||||
"last_quarter": "Último trimestre",
|
||||
"last_year": "Último ano",
|
||||
"link_to_public_results_copied": "Link pros resultados públicos copiado",
|
||||
"links_generated_success_toast": "Links gerados com sucesso, o download começará em breve.",
|
||||
"make_sure_the_survey_type_is_set_to": "Certifique-se de que o tipo de pesquisa esteja definido como",
|
||||
"mobile_app": "app de celular",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"no_segments_available": "Nenhum segmento disponível",
|
||||
"only_completed": "Somente concluído",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"overall": "No geral",
|
||||
"personal_links": "Links pessoais",
|
||||
"personal_links_upgrade_prompt_description": "Gerar links pessoais para um segmento e vincular respostas de pesquisa a cada contato.",
|
||||
"personal_links_upgrade_prompt_title": "Use links pessoais com um plano superior",
|
||||
"personal_links_work_with_segments": "Links pessoais funcionam com segmentos.",
|
||||
"publish_to_web": "Publicar na web",
|
||||
"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.",
|
||||
@@ -1772,6 +1791,7 @@
|
||||
"quickstart_web_apps": "Início rápido: Aplicativos web",
|
||||
"quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:",
|
||||
"results_are_public": "Os resultados são públicos",
|
||||
"select_segment": "Selecionar segmento",
|
||||
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
||||
"selected_responses_excel": "Respostas selecionadas (Excel)",
|
||||
"send_preview": "Enviar prévia",
|
||||
@@ -1797,6 +1817,7 @@
|
||||
"this_year": "Este ano",
|
||||
"time_to_complete": "Tempo para Concluir",
|
||||
"to_connect_your_website_with_formbricks": "conectar seu site com o Formbricks",
|
||||
"to_create_personal_links_segment_required": "Para criar links pessoais para sua pesquisa, você precisa configurar um segmento primeiro.",
|
||||
"ttc_tooltip": "Tempo médio para completar a pesquisa.",
|
||||
"unknown_question_type": "Tipo de pergunta desconhecido",
|
||||
"unpublish_from_web": "Despublicar da web",
|
||||
|
||||
@@ -108,6 +108,10 @@
|
||||
"thanks_for_upgrading": "Muito obrigado por atualizar a sua subscrição do Formbricks.",
|
||||
"upgrade_successful": "Atualização bem-sucedida"
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "O seu link expirou.",
|
||||
"link_expired_description": "O link que utilizou já não é válido."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Aceite",
|
||||
"account": "Conta",
|
||||
@@ -313,6 +317,7 @@
|
||||
"question_id": "ID da pergunta",
|
||||
"questions": "Perguntas",
|
||||
"read_docs": "Ler Documentos",
|
||||
"recipients": "Destinatários",
|
||||
"remove": "Remover",
|
||||
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
|
||||
"report_survey": "Relatório de Inquérito",
|
||||
@@ -1709,6 +1714,7 @@
|
||||
"congrats": "Parabéns! O seu inquérito está ativo.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Ligue o seu website ou aplicação ao Formbricks para começar.",
|
||||
"copy_link_to_public_results": "Copiar link para resultados públicos",
|
||||
"create_and_manage_segments": "Crie e gere os seus Segmentos em Contactos > Segmentos",
|
||||
"create_single_use_links": "Criar links de uso único",
|
||||
"create_single_use_links_description": "Aceitar apenas uma submissão por link. Aqui está como.",
|
||||
"custom_range": "Intervalo personalizado...",
|
||||
@@ -1727,12 +1733,19 @@
|
||||
"embed_on_website": "Incorporar no site",
|
||||
"embed_pop_up_survey_title": "Como incorporar um questionário pop-up no seu site",
|
||||
"embed_survey": "Incorporar inquérito",
|
||||
"expiry_date_description": "Uma vez que o link expira, o destinatário não pode mais responder ao questionário.",
|
||||
"expiry_date_optional": "Data de expiração (opcional)",
|
||||
"failed_to_copy_link": "Falha ao copiar link",
|
||||
"filter_added_successfully": "Filtro adicionado com sucesso",
|
||||
"filter_updated_successfully": "Filtro atualizado com sucesso",
|
||||
"filtered_responses_csv": "Respostas filtradas (CSV)",
|
||||
"filtered_responses_excel": "Respostas filtradas (Excel)",
|
||||
"formbricks_email_survey_preview": "Pré-visualização da Pesquisa de E-mail do Formbricks",
|
||||
"generate_and_download_links": "Gerar & descarregar links",
|
||||
"generate_personal_links_description": "Gerar links pessoais para um segmento e associar as respostas do inquérito a cada contacto. Um ficheiro CSV dos seus links pessoais, incluindo a informação relevante de contacto, será descarregado automaticamente.",
|
||||
"generate_personal_links_title": "Maximize os insights com links pessoais de inquérito",
|
||||
"generating_links": "Gerando links",
|
||||
"generating_links_toast": "A gerar links, o download começará em breve…",
|
||||
"go_to_setup_checklist": "Ir para a Lista de Verificação de Configuração \uD83D\uDC49",
|
||||
"hide_embed_code": "Ocultar código de incorporação",
|
||||
"how_to_create_a_panel": "Como criar um painel",
|
||||
@@ -1758,12 +1771,18 @@
|
||||
"last_quarter": "Último trimestre",
|
||||
"last_year": "Ano passado",
|
||||
"link_to_public_results_copied": "Link para resultados públicos copiado",
|
||||
"links_generated_success_toast": "Links gerados com sucesso, o seu download começará em breve.",
|
||||
"make_sure_the_survey_type_is_set_to": "Certifique-se de que o tipo de inquérito está definido para",
|
||||
"mobile_app": "Aplicação móvel",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"no_segments_available": "Sem segmentos disponíveis",
|
||||
"only_completed": "Apenas concluído",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"overall": "Geral",
|
||||
"personal_links": "Links pessoais",
|
||||
"personal_links_upgrade_prompt_description": "Gerar links pessoais para um segmento e associar as respostas do inquérito a cada contacto.",
|
||||
"personal_links_upgrade_prompt_title": "Utilize links pessoais com um plano superior",
|
||||
"personal_links_work_with_segments": "Os links pessoais funcionam com segmentos.",
|
||||
"publish_to_web": "Publicar na web",
|
||||
"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.",
|
||||
@@ -1772,6 +1791,7 @@
|
||||
"quickstart_web_apps": "Início rápido: Aplicações web",
|
||||
"quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:",
|
||||
"results_are_public": "Os resultados são públicos",
|
||||
"select_segment": "Selecionar segmento",
|
||||
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
||||
"selected_responses_excel": "Respostas selecionadas (Excel)",
|
||||
"send_preview": "Enviar pré-visualização",
|
||||
@@ -1797,6 +1817,7 @@
|
||||
"this_year": "Este ano",
|
||||
"time_to_complete": "Tempo para Concluir",
|
||||
"to_connect_your_website_with_formbricks": "para ligar o seu website ao Formbricks",
|
||||
"to_create_personal_links_segment_required": "Para criar links pessoais para o seu inquérito, é necessário configurar primeiro um segmento.",
|
||||
"ttc_tooltip": "Tempo médio para concluir o inquérito.",
|
||||
"unknown_question_type": "Tipo de Pergunta Desconhecido",
|
||||
"unpublish_from_web": "Despublicar da web",
|
||||
|
||||
@@ -108,6 +108,10 @@
|
||||
"thanks_for_upgrading": "非常感謝您升級您的 Formbricks 訂閱。",
|
||||
"upgrade_successful": "升級成功"
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "您 的 連結 已過期。",
|
||||
"link_expired_description": "您 使用 的 連結 已無效。"
|
||||
},
|
||||
"common": {
|
||||
"accepted": "已接受",
|
||||
"account": "帳戶",
|
||||
@@ -313,6 +317,7 @@
|
||||
"question_id": "問題 ID",
|
||||
"questions": "問題",
|
||||
"read_docs": "閱讀文件",
|
||||
"recipients": "收件者",
|
||||
"remove": "移除",
|
||||
"reorder_and_hide_columns": "重新排序和隱藏欄位",
|
||||
"report_survey": "報告問卷",
|
||||
@@ -1709,6 +1714,7 @@
|
||||
"congrats": "恭喜!您的問卷已上線。",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "將您的網站或應用程式與 Formbricks 連線以開始使用。",
|
||||
"copy_link_to_public_results": "複製公開結果的連結",
|
||||
"create_and_manage_segments": "在 聯絡人 > 分段 中建立和管理您的分段",
|
||||
"create_single_use_links": "建立單次使用連結",
|
||||
"create_single_use_links_description": "每個連結只接受一次提交。以下是如何操作。",
|
||||
"custom_range": "自訂範圍...",
|
||||
@@ -1727,12 +1733,19 @@
|
||||
"embed_on_website": "嵌入網站",
|
||||
"embed_pop_up_survey_title": "如何在您的網站上嵌入彈出式問卷",
|
||||
"embed_survey": "嵌入問卷",
|
||||
"expiry_date_description": "一旦連結過期,收件者將無法再回應 survey。",
|
||||
"expiry_date_optional": "到期日 (可選)",
|
||||
"failed_to_copy_link": "無法複製連結",
|
||||
"filter_added_successfully": "篩選器已成功新增",
|
||||
"filter_updated_successfully": "篩選器已成功更新",
|
||||
"filtered_responses_csv": "篩選回應 (CSV)",
|
||||
"filtered_responses_excel": "篩選回應 (Excel)",
|
||||
"formbricks_email_survey_preview": "Formbricks 電子郵件問卷預覽",
|
||||
"generate_and_download_links": "生成 & 下載 連結",
|
||||
"generate_personal_links_description": "為 一個 群組 生成 個人 連結,並 將 調查 回應 對應 到 每個 聯絡人。含 有 相關 聯絡信息 的 個人 連結 CSV 會 自動 下載。",
|
||||
"generate_personal_links_title": "透過個人化調查連結最大化洞察",
|
||||
"generating_links": "生成 連結",
|
||||
"generating_links_toast": "生成 連結,下載 將 會 很快 開始…",
|
||||
"go_to_setup_checklist": "前往設定檢查清單 \uD83D\uDC49",
|
||||
"hide_embed_code": "隱藏嵌入程式碼",
|
||||
"how_to_create_a_panel": "如何建立小組",
|
||||
@@ -1758,12 +1771,18 @@
|
||||
"last_quarter": "上一季",
|
||||
"last_year": "去年",
|
||||
"link_to_public_results_copied": "已複製公開結果的連結",
|
||||
"links_generated_success_toast": "連結 成功 生成,您的 下載 將 會 很快 開始。",
|
||||
"make_sure_the_survey_type_is_set_to": "請確保問卷類型設定為",
|
||||
"mobile_app": "行動應用程式",
|
||||
"no_responses_found": "找不到回應",
|
||||
"no_segments_available": "沒有可用的區段",
|
||||
"only_completed": "僅已完成",
|
||||
"other_values_found": "找到其他值",
|
||||
"overall": "整體",
|
||||
"personal_links": "個人 連結",
|
||||
"personal_links_upgrade_prompt_description": "為一個群組生成個人連結,並將調查回應連結到每個聯絡人。",
|
||||
"personal_links_upgrade_prompt_title": "使用 個人 連結 與 更高 的 計劃",
|
||||
"personal_links_work_with_segments": "個人 連結 可 與 分段 一起 使用",
|
||||
"publish_to_web": "發布至網站",
|
||||
"publish_to_web_warning": "您即將將這些問卷結果發布到公共領域。",
|
||||
"publish_to_web_warning_description": "您的問卷結果將會是公開的。任何組織外的人員都可以存取這些結果(如果他們有連結)。",
|
||||
@@ -1772,6 +1791,7 @@
|
||||
"quickstart_web_apps": "快速入門:Web apps",
|
||||
"quickstart_web_apps_description": "請按照 Quickstart 指南開始:",
|
||||
"results_are_public": "結果是公開的",
|
||||
"select_segment": "選擇 區隔",
|
||||
"selected_responses_csv": "選擇的回應 (CSV)",
|
||||
"selected_responses_excel": "選擇的回應 (Excel)",
|
||||
"send_preview": "發送預覽",
|
||||
@@ -1797,6 +1817,7 @@
|
||||
"this_year": "今年",
|
||||
"time_to_complete": "完成時間",
|
||||
"to_connect_your_website_with_formbricks": "以將您的網站與 Formbricks 連線",
|
||||
"to_create_personal_links_segment_required": "要為 問卷 創建 個人連結,您 必須先 設置 一個 分段。",
|
||||
"ttc_tooltip": "完成問卷的平均時間。",
|
||||
"unknown_question_type": "未知的問題類型",
|
||||
"unpublish_from_web": "從網站取消發布",
|
||||
|
||||
@@ -13,6 +13,7 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
|
||||
autoFocus={true}
|
||||
className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-slate-800 caret-transparent"
|
||||
value={surveyUrl}
|
||||
readOnly
|
||||
/>
|
||||
) : (
|
||||
//loading state
|
||||
|
||||
@@ -10,6 +10,12 @@ vi.mock("jsonwebtoken", () => ({
|
||||
default: {
|
||||
sign: vi.fn(),
|
||||
verify: vi.fn(),
|
||||
TokenExpiredError: class TokenExpiredError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "TokenExpiredError";
|
||||
}
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -145,8 +151,8 @@ describe("Contact Survey Link", () => {
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "bad_request",
|
||||
message: "Invalid or expired survey token",
|
||||
details: [{ field: "token", issue: "Invalid or expired survey token" }],
|
||||
message: "Invalid survey token",
|
||||
details: [{ field: "token", issue: "invalid_token" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -166,8 +172,8 @@ describe("Contact Survey Link", () => {
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "bad_request",
|
||||
message: "Invalid or expired survey token",
|
||||
details: [{ field: "token", issue: "Invalid or expired survey token" }],
|
||||
message: "Invalid survey token",
|
||||
details: [{ field: "token", issue: "invalid_token" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
// Creates an encrypted personalized survey link for a contact
|
||||
@@ -73,11 +74,22 @@ export const verifyContactSurveyToken = (
|
||||
surveyId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error verifying contact survey token:", error);
|
||||
logger.error("Error verifying contact survey token:", error);
|
||||
|
||||
// Check if the error is specifically a JWT expiration error
|
||||
if (error instanceof jwt.TokenExpiredError) {
|
||||
return err({
|
||||
type: "bad_request",
|
||||
message: "Survey link has expired",
|
||||
details: [{ field: "token", issue: "token_expired" }],
|
||||
});
|
||||
}
|
||||
|
||||
// Handle other JWT errors or general validation errors
|
||||
return err({
|
||||
type: "bad_request",
|
||||
message: "Invalid or expired survey token",
|
||||
details: [{ field: "token", issue: "Invalid or expired survey token" }],
|
||||
message: "Invalid survey token",
|
||||
details: [{ field: "token", issue: "invalid_token" }],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,10 +6,31 @@ import {
|
||||
buildContactWhereClause,
|
||||
createContactsFromCSV,
|
||||
deleteContact,
|
||||
generatePersonalLinks,
|
||||
getContact,
|
||||
getContacts,
|
||||
getContactsInSegment,
|
||||
} from "./contacts";
|
||||
|
||||
// Mock additional dependencies for the new functions
|
||||
vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({
|
||||
getSegment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/contacts/segments/lib/filter/prisma-query", () => ({
|
||||
segmentFilterToPrismaQuery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/contacts/lib/contact-survey-link", () => ({
|
||||
getContactSurveyLink: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contact: {
|
||||
@@ -31,11 +52,18 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/constants", () => ({ ITEMS_PER_PAGE: 2 }));
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
ITEMS_PER_PAGE: 2,
|
||||
ENCRYPTION_KEY: "test-encryption-key-32-chars-long!",
|
||||
IS_PRODUCTION: false,
|
||||
IS_POSTHOG_CONFIGURED: false,
|
||||
POSTHOG_API_HOST: "test-posthog-host",
|
||||
POSTHOG_API_KEY: "test-posthog-key",
|
||||
}));
|
||||
|
||||
const environmentId = "env1";
|
||||
const contactId = "contact1";
|
||||
const userId = "user1";
|
||||
const environmentId = "cm123456789012345678901237";
|
||||
const contactId = "cm123456789012345678901238";
|
||||
const userId = "cm123456789012345678901239";
|
||||
const mockContact: Contact & {
|
||||
attributes: { value: string; attributeKey: { key: string; name: string } }[];
|
||||
} = {
|
||||
@@ -159,7 +187,7 @@ describe("createContactsFromCSV", () => {
|
||||
.mockResolvedValueOnce([
|
||||
{ key: "email", id: "id-email" },
|
||||
{ key: "name", id: "id-name" },
|
||||
]);
|
||||
] as any);
|
||||
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 2 });
|
||||
vi.mocked(prisma.contact.create).mockResolvedValue({
|
||||
id: "c1",
|
||||
@@ -183,12 +211,12 @@ describe("createContactsFromCSV", () => {
|
||||
test("skips duplicate contact with 'skip' action", async () => {
|
||||
vi.mocked(prisma.contact.findMany).mockResolvedValue([
|
||||
{ id: "c1", attributes: [{ attributeKey: { key: "email" }, value: "john@example.com" }] },
|
||||
]);
|
||||
] as any);
|
||||
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
|
||||
{ key: "email", id: "id-email" },
|
||||
{ key: "name", id: "id-name" },
|
||||
]);
|
||||
] as any);
|
||||
const csvData = [{ email: "john@example.com", name: "John" }];
|
||||
const result = await createContactsFromCSV(csvData, environmentId, "skip", {
|
||||
email: "email",
|
||||
@@ -206,12 +234,12 @@ describe("createContactsFromCSV", () => {
|
||||
{ attributeKey: { key: "name" }, value: "Old" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
] as any);
|
||||
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
|
||||
{ key: "email", id: "id-email" },
|
||||
{ key: "name", id: "id-name" },
|
||||
]);
|
||||
] as any);
|
||||
vi.mocked(prisma.contact.update).mockResolvedValue({
|
||||
id: "c1",
|
||||
environmentId,
|
||||
@@ -239,12 +267,12 @@ describe("createContactsFromCSV", () => {
|
||||
{ attributeKey: { key: "name" }, value: "Old" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
] as any);
|
||||
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
|
||||
{ key: "email", id: "id-email" },
|
||||
{ key: "name", id: "id-name" },
|
||||
]);
|
||||
] as any);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 2 });
|
||||
vi.mocked(prisma.contact.update).mockResolvedValue({
|
||||
id: "c1",
|
||||
@@ -326,8 +354,333 @@ describe("buildContactWhereClause", () => {
|
||||
});
|
||||
|
||||
test("returns where clause without search", () => {
|
||||
const environmentId = "env-1";
|
||||
const environmentId = "cm123456789012345678901240";
|
||||
const result = buildContactWhereClause(environmentId);
|
||||
expect(result).toEqual({ environmentId });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContactsInSegment", () => {
|
||||
const mockSegmentId = "cm123456789012345678901235";
|
||||
const mockEnvironmentId = "cm123456789012345678901236";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns contacts when segment and filters are valid", async () => {
|
||||
const mockSegment = {
|
||||
id: mockSegmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: mockEnvironmentId,
|
||||
description: "Test segment",
|
||||
title: "Test Segment",
|
||||
isPrivate: false,
|
||||
surveys: [],
|
||||
filters: [],
|
||||
};
|
||||
|
||||
const mockContacts = [
|
||||
{
|
||||
id: "contact-1",
|
||||
attributes: [
|
||||
{ attributeKey: { key: "email" }, value: "test@example.com" },
|
||||
{ attributeKey: { key: "name" }, value: "Test User" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "contact-2",
|
||||
attributes: [
|
||||
{ attributeKey: { key: "email" }, value: "another@example.com" },
|
||||
{ attributeKey: { key: "name" }, value: "Another User" },
|
||||
],
|
||||
},
|
||||
] as any;
|
||||
|
||||
const mockWhereClause = {
|
||||
environmentId: mockEnvironmentId,
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: { key: "email" },
|
||||
value: "test@example.com",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Mock the dependencies
|
||||
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
|
||||
const { segmentFilterToPrismaQuery } = await import(
|
||||
"@/modules/ee/contacts/segments/lib/filter/prisma-query"
|
||||
);
|
||||
|
||||
vi.mocked(getSegment).mockResolvedValue(mockSegment);
|
||||
|
||||
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
|
||||
ok: true,
|
||||
data: { whereClause: mockWhereClause },
|
||||
} as any);
|
||||
|
||||
vi.mocked(prisma.contact.findMany).mockResolvedValue(mockContacts);
|
||||
|
||||
const result = await getContactsInSegment(mockSegmentId);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
contactId: "contact-1",
|
||||
attributes: {
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
},
|
||||
},
|
||||
{
|
||||
contactId: "contact-2",
|
||||
attributes: {
|
||||
email: "another@example.com",
|
||||
name: "Another User",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(prisma.contact.findMany).toHaveBeenCalledWith({
|
||||
where: mockWhereClause,
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: {
|
||||
in: ["userId", "firstName", "lastName", "email"],
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
attributeKey: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null when segment is not found", async () => {
|
||||
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
|
||||
|
||||
vi.mocked(getSegment).mockRejectedValue(new Error("Segment not found"));
|
||||
|
||||
const result = await getContactsInSegment(mockSegmentId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when segment filter to prisma query fails", async () => {
|
||||
const mockSegment = {
|
||||
id: mockSegmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: mockEnvironmentId,
|
||||
description: "Test segment",
|
||||
title: "Test Segment",
|
||||
isPrivate: false,
|
||||
surveys: [],
|
||||
filters: [],
|
||||
};
|
||||
|
||||
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
|
||||
const { segmentFilterToPrismaQuery } = await import(
|
||||
"@/modules/ee/contacts/segments/lib/filter/prisma-query"
|
||||
);
|
||||
|
||||
vi.mocked(getSegment).mockResolvedValue(mockSegment);
|
||||
|
||||
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
|
||||
ok: false,
|
||||
error: { type: "bad_request" },
|
||||
} as any);
|
||||
|
||||
const result = await getContactsInSegment(mockSegmentId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when prisma query fails", async () => {
|
||||
const mockSegment = {
|
||||
id: mockSegmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: mockEnvironmentId,
|
||||
description: "Test segment",
|
||||
title: "Test Segment",
|
||||
isPrivate: false,
|
||||
surveys: [],
|
||||
filters: [],
|
||||
};
|
||||
|
||||
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
|
||||
const { segmentFilterToPrismaQuery } = await import(
|
||||
"@/modules/ee/contacts/segments/lib/filter/prisma-query"
|
||||
);
|
||||
|
||||
vi.mocked(getSegment).mockResolvedValue(mockSegment);
|
||||
|
||||
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
|
||||
ok: true,
|
||||
data: { whereClause: {} },
|
||||
} as any);
|
||||
|
||||
vi.mocked(prisma.contact.findMany).mockRejectedValue(new Error("Database error"));
|
||||
|
||||
const result = await getContactsInSegment(mockSegmentId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("handles errors gracefully", async () => {
|
||||
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
|
||||
|
||||
vi.mocked(getSegment).mockRejectedValue(new Error("Database error"));
|
||||
|
||||
const result = await getContactsInSegment(mockSegmentId);
|
||||
|
||||
expect(result).toBeNull(); // The function catches errors and returns null
|
||||
});
|
||||
});
|
||||
|
||||
describe("generatePersonalLinks", () => {
|
||||
const mockSurveyId = "cm123456789012345678901234"; // Valid CUID2 format
|
||||
const mockSegmentId = "cm123456789012345678901235"; // Valid CUID2 format
|
||||
const mockExpirationDays = 7;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns null when getContactsInSegment fails", async () => {
|
||||
// Mock getSegment to fail which will cause getContactsInSegment to return null
|
||||
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
|
||||
|
||||
vi.mocked(getSegment).mockRejectedValue(new Error("Segment not found"));
|
||||
|
||||
const result = await generatePersonalLinks(mockSurveyId, mockSegmentId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns empty array when no contacts in segment", async () => {
|
||||
// Mock successful segment retrieval but no contacts
|
||||
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
|
||||
const { segmentFilterToPrismaQuery } = await import(
|
||||
"@/modules/ee/contacts/segments/lib/filter/prisma-query"
|
||||
);
|
||||
|
||||
vi.mocked(getSegment).mockResolvedValue({
|
||||
id: mockSegmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env-123",
|
||||
description: "Test segment",
|
||||
title: "Test Segment",
|
||||
isPrivate: false,
|
||||
surveys: [],
|
||||
filters: [],
|
||||
});
|
||||
|
||||
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
|
||||
ok: true,
|
||||
data: { whereClause: {} },
|
||||
} as any);
|
||||
|
||||
vi.mocked(prisma.contact.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await generatePersonalLinks(mockSurveyId, mockSegmentId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("generates personal links for contacts successfully", async () => {
|
||||
// Mock all the dependencies that getContactsInSegment needs
|
||||
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
|
||||
const { segmentFilterToPrismaQuery } = await import(
|
||||
"@/modules/ee/contacts/segments/lib/filter/prisma-query"
|
||||
);
|
||||
const { getContactSurveyLink } = await import("@/modules/ee/contacts/lib/contact-survey-link");
|
||||
|
||||
vi.mocked(getSegment).mockResolvedValue({
|
||||
id: mockSegmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env-123",
|
||||
description: "Test segment",
|
||||
title: "Test Segment",
|
||||
isPrivate: false,
|
||||
surveys: [],
|
||||
filters: [],
|
||||
});
|
||||
|
||||
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
|
||||
ok: true,
|
||||
data: { whereClause: {} },
|
||||
} as any);
|
||||
|
||||
vi.mocked(prisma.contact.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "contact-1",
|
||||
attributes: [
|
||||
{ attributeKey: { key: "email" }, value: "test@example.com" },
|
||||
{ attributeKey: { key: "name" }, value: "Test User" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "contact-2",
|
||||
attributes: [
|
||||
{ attributeKey: { key: "email" }, value: "another@example.com" },
|
||||
{ attributeKey: { key: "name" }, value: "Another User" },
|
||||
],
|
||||
},
|
||||
] as any);
|
||||
|
||||
// Mock getContactSurveyLink to return successful results
|
||||
vi.mocked(getContactSurveyLink)
|
||||
.mockReturnValueOnce({
|
||||
ok: true,
|
||||
data: "https://example.com/survey/link1",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
ok: true,
|
||||
data: "https://example.com/survey/link2",
|
||||
});
|
||||
|
||||
const result = await generatePersonalLinks(mockSurveyId, mockSegmentId, mockExpirationDays);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
contactId: "contact-1",
|
||||
attributes: {
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
},
|
||||
surveyUrl: "https://example.com/survey/link1",
|
||||
expirationDays: mockExpirationDays,
|
||||
},
|
||||
{
|
||||
contactId: "contact-2",
|
||||
attributes: {
|
||||
email: "another@example.com",
|
||||
name: "Another User",
|
||||
},
|
||||
surveyUrl: "https://example.com/survey/link2",
|
||||
expirationDays: mockExpirationDays,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(getContactSurveyLink).toHaveBeenCalledWith("contact-1", mockSurveyId, mockExpirationDays);
|
||||
expect(getContactSurveyLink).toHaveBeenCalledWith("contact-2", mockSurveyId, mockExpirationDays);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import "server-only";
|
||||
import { ITEMS_PER_PAGE } from "@/lib/constants";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
|
||||
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
|
||||
import { getSegment } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZOptionalNumber, ZOptionalString } from "@formbricks/types/common";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import {
|
||||
@@ -15,6 +19,76 @@ import {
|
||||
} from "../types/contact";
|
||||
import { transformPrismaContact } from "./utils";
|
||||
|
||||
export const getContactsInSegment = reactCache(async (segmentId: string) => {
|
||||
try {
|
||||
const segment = await getSegment(segmentId);
|
||||
|
||||
if (!segment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segmentFilterToPrismaQueryResult = await segmentFilterToPrismaQuery(
|
||||
segment.id,
|
||||
segment.filters,
|
||||
segment.environmentId
|
||||
);
|
||||
|
||||
if (!segmentFilterToPrismaQueryResult.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { whereClause } = segmentFilterToPrismaQueryResult.data;
|
||||
|
||||
const requiredAttributes = ["userId", "firstName", "lastName", "email"];
|
||||
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: whereClause,
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: {
|
||||
in: requiredAttributes,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
attributeKey: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
const contactsWithAttributes = contacts.map((contact) => {
|
||||
const attributes = contact.attributes.reduce(
|
||||
(acc, attr) => {
|
||||
acc[attr.attributeKey.key] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
return {
|
||||
contactId: contact.id,
|
||||
attributes,
|
||||
};
|
||||
});
|
||||
|
||||
return contactsWithAttributes;
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to get contacts in segment");
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const selectContact = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
@@ -418,3 +492,37 @@ export const createContactsFromCSV = async (
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const generatePersonalLinks = async (surveyId: string, segmentId: string, expirationDays?: number) => {
|
||||
const contactsResult = await getContactsInSegment(segmentId);
|
||||
|
||||
if (!contactsResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate survey links for each contact
|
||||
const contactLinks = contactsResult
|
||||
.map((contact) => {
|
||||
const { contactId, attributes } = contact;
|
||||
|
||||
const surveyUrlResult = getContactSurveyLink(contactId, surveyId, expirationDays);
|
||||
|
||||
if (!surveyUrlResult.ok) {
|
||||
logger.error(
|
||||
{ error: surveyUrlResult.error, contactId: contactId, surveyId: surveyId },
|
||||
"Failed to generate survey URL for contact"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
contactId,
|
||||
attributes,
|
||||
surveyUrl: surveyUrlResult.data,
|
||||
expirationDays,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return contactLinks;
|
||||
};
|
||||
|
||||
@@ -38,6 +38,9 @@ vi.mock("lucide-react", () => ({
|
||||
HelpCircleIcon: ({ className }: { className: string }) => (
|
||||
<div className={className} data-testid="help-icon" />
|
||||
),
|
||||
CalendarClockIcon: ({ className }: { className: string }) => (
|
||||
<div className={className} data-testid="calendar-clock-icon" />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { Project } from "@prisma/client";
|
||||
import { CheckCircle2Icon, HelpCircleIcon, PauseCircleIcon } from "lucide-react";
|
||||
import { CalendarClockIcon, CheckCircle2Icon, HelpCircleIcon, PauseCircleIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { TSurveyClosedMessage } from "@formbricks/types/surveys/types";
|
||||
@@ -12,7 +12,7 @@ export const SurveyInactive = async ({
|
||||
surveyClosedMessage,
|
||||
project,
|
||||
}: {
|
||||
status: "paused" | "completed" | "link invalid" | "scheduled" | "response submitted";
|
||||
status: "paused" | "completed" | "link invalid" | "scheduled" | "response submitted" | "link expired";
|
||||
surveyClosedMessage?: TSurveyClosedMessage | null;
|
||||
project?: Pick<Project, "linkSurveyBranding">;
|
||||
}) => {
|
||||
@@ -22,6 +22,7 @@ export const SurveyInactive = async ({
|
||||
completed: <CheckCircle2Icon className="h-20 w-20" />,
|
||||
"link invalid": <HelpCircleIcon className="h-20 w-20" />,
|
||||
"response submitted": <CheckCircle2Icon className="h-20 w-20" />,
|
||||
"link expired": <CalendarClockIcon className="h-20 w-20" />,
|
||||
};
|
||||
|
||||
const descriptions = {
|
||||
@@ -29,10 +30,12 @@ export const SurveyInactive = async ({
|
||||
completed: t("s.completed"),
|
||||
"link invalid": t("s.link_invalid"),
|
||||
"response submitted": t("s.response_submitted"),
|
||||
"link expired": t("c.link_expired_description"),
|
||||
};
|
||||
|
||||
const showCTA =
|
||||
status !== "link invalid" &&
|
||||
status !== "link expired" &&
|
||||
status !== "response submitted" &&
|
||||
((status !== "paused" && status !== "completed") || project?.linkSurveyBranding || !project) &&
|
||||
!(status === "completed" && surveyClosedMessage);
|
||||
@@ -42,7 +45,7 @@ export const SurveyInactive = async ({
|
||||
<div className="my-auto flex flex-col items-center space-y-3 text-slate-300">
|
||||
{icons[status]}
|
||||
<h1 className="text-4xl font-bold text-slate-800">
|
||||
{status === "completed" && surveyClosedMessage
|
||||
{(status === "completed" || status === "link expired") && surveyClosedMessage
|
||||
? surveyClosedMessage.heading
|
||||
: `${t("common.survey")} ${status}.`}
|
||||
</h1>
|
||||
|
||||
@@ -39,6 +39,9 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_PORT: 587,
|
||||
SMTP_USERNAME: "user@example.com",
|
||||
SMTP_PASSWORD: "password",
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
AUDIT_LOG_ENABLED: false,
|
||||
SESSION_MAX_AGE: 86400, // 24 hours in seconds
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
@@ -64,6 +67,12 @@ vi.mock("@/modules/survey/link/components/survey-inactive", () => ({
|
||||
vi.mock("@/modules/survey/link/components/survey-renderer", () => ({
|
||||
renderSurvey: vi.fn(() => <div>Rendered Survey</div>),
|
||||
}));
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: vi.fn(() => Promise.resolve((key: string) => key)),
|
||||
}));
|
||||
vi.mock("@/modules/survey/link/lib/project", () => ({
|
||||
getProjectByEnvironmentId: vi.fn(() => Promise.resolve(null)),
|
||||
}));
|
||||
|
||||
describe("contact-survey page", () => {
|
||||
afterEach(() => {
|
||||
@@ -72,7 +81,13 @@ describe("contact-survey page", () => {
|
||||
});
|
||||
|
||||
test("generateMetadata returns default when token invalid", async () => {
|
||||
vi.mocked(verifyContactSurveyToken).mockReturnValue({ ok: false } as any);
|
||||
vi.mocked(verifyContactSurveyToken).mockReturnValue({
|
||||
ok: false,
|
||||
error: {
|
||||
type: "invalid_token",
|
||||
details: [],
|
||||
},
|
||||
} as any);
|
||||
const meta = await generateMetadata({
|
||||
params: Promise.resolve({ jwt: "token" }),
|
||||
searchParams: Promise.resolve({}),
|
||||
@@ -102,7 +117,13 @@ describe("contact-survey page", () => {
|
||||
});
|
||||
|
||||
test("ContactSurveyPage shows link invalid when token invalid", async () => {
|
||||
vi.mocked(verifyContactSurveyToken).mockReturnValue({ ok: false } as any);
|
||||
vi.mocked(verifyContactSurveyToken).mockReturnValue({
|
||||
ok: false,
|
||||
error: {
|
||||
type: "invalid_token",
|
||||
details: [],
|
||||
},
|
||||
} as any);
|
||||
render(
|
||||
await ContactSurveyPage({
|
||||
params: Promise.resolve({ jwt: "tk" }),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
|
||||
import { getExistingContactResponse } from "@/modules/survey/link/lib/data";
|
||||
import { getBasicSurveyMetadata } from "@/modules/survey/link/lib/metadata-utils";
|
||||
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
@@ -45,12 +46,18 @@ export const generateMetadata = async (props: ContactSurveyPageProps): Promise<M
|
||||
export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
|
||||
const t = await getTranslate();
|
||||
const { jwt } = params;
|
||||
const { preview } = searchParams;
|
||||
|
||||
const result = verifyContactSurveyToken(jwt);
|
||||
if (!result.ok) {
|
||||
if (
|
||||
result.error.type === "bad_request" &&
|
||||
result.error.details?.some((detail) => detail.issue === "token_expired")
|
||||
) {
|
||||
return <SurveyInactive surveyClosedMessage={{ heading: t("c.link_expired") }} status="link expired" />;
|
||||
}
|
||||
// When token is invalid, we don't have survey data to get project branding settings
|
||||
// So we show SurveyInactive without project data (shows branding by default for backward compatibility)
|
||||
return <SurveyInactive status="link invalid" />;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button } from "@/modules/ui/components/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { format } from "date-fns";
|
||||
import { CalendarCheckIcon, CalendarIcon } from "lucide-react";
|
||||
import { CalendarCheckIcon, CalendarIcon, XIcon } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import Calendar from "react-calendar";
|
||||
import "./styles.css";
|
||||
@@ -27,9 +27,11 @@ const getOrdinalSuffix = (day: number) => {
|
||||
interface DatePickerProps {
|
||||
date: Date | null;
|
||||
updateSurveyDate: (date: Date) => void;
|
||||
minDate?: Date;
|
||||
onClearDate?: () => void;
|
||||
}
|
||||
|
||||
export const DatePicker = ({ date, updateSurveyDate }: DatePickerProps) => {
|
||||
export const DatePicker = ({ date, updateSurveyDate, minDate, onClearDate }: DatePickerProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [value, onChange] = useState<Date | undefined>(date ? new Date(date) : undefined);
|
||||
const [formattedDate, setFormattedDate] = useState<string | undefined>(
|
||||
@@ -51,65 +53,84 @@ export const DatePicker = ({ date, updateSurveyDate }: DatePickerProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{formattedDate ? (
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
className={cn(
|
||||
"w-[280px] justify-start border border-slate-300 bg-white text-left font-normal transition-all ease-in hover:bg-slate-300",
|
||||
!formattedDate && "text-muted-foreground bg-slate-800"
|
||||
)}
|
||||
ref={btnRef}>
|
||||
<CalendarCheckIcon className="mr-2 h-4 w-4" />
|
||||
{formattedDate}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
className={cn(
|
||||
"w-[280px] justify-start border border-slate-300 bg-white text-left font-normal hover:bg-slate-300",
|
||||
!formattedDate && "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setIsOpen(true)}
|
||||
ref={btnRef}>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
<span>{t("common.pick_a_date")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="min-w-96 rounded-lg px-4 py-3">
|
||||
<Calendar
|
||||
value={value}
|
||||
onChange={(date) => onDateChange(date as Date)}
|
||||
minDate={new Date()}
|
||||
className="!border-0"
|
||||
tileClassName={({ date }: { date: Date }) => {
|
||||
const baseClass =
|
||||
"hover:fb-bg-input-bg-selected fb-rounded-custom fb-h-9 fb-p-0 fb-mt-1 fb-font-normal fb-text-heading aria-selected:fb-opacity-100 focus:fb-ring-2 focus:fb-bg-slate-200";
|
||||
// today's date class
|
||||
if (
|
||||
date.getDate() === new Date().getDate() &&
|
||||
date.getMonth() === new Date().getMonth() &&
|
||||
date.getFullYear() === new Date().getFullYear()
|
||||
) {
|
||||
return `${baseClass} !fb-bg-brand !fb-border-border-highlight !fb-text-heading focus:fb-ring-2 focus:fb-bg-slate-200`;
|
||||
}
|
||||
// active date class
|
||||
if (
|
||||
date.getDate() === value?.getDate() &&
|
||||
date.getMonth() === value?.getMonth() &&
|
||||
date.getFullYear() === value?.getFullYear()
|
||||
) {
|
||||
return `${baseClass} !fb-bg-brand !fb-border-border-highlight !fb-text-heading`;
|
||||
}
|
||||
const handleClearDate = () => {
|
||||
if (onClearDate) {
|
||||
onClearDate();
|
||||
setFormattedDate(undefined);
|
||||
onChange(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
return baseClass;
|
||||
}}
|
||||
showNeighboringMonth={false}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{formattedDate ? (
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
className={cn(
|
||||
"w-[280px] justify-start border border-slate-300 bg-white text-left font-normal transition-all ease-in hover:bg-slate-300",
|
||||
!formattedDate && "text-muted-foreground bg-slate-800"
|
||||
)}
|
||||
ref={btnRef}>
|
||||
<CalendarCheckIcon className="mr-2 h-4 w-4" />
|
||||
{formattedDate}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
className={cn(
|
||||
"w-[280px] justify-start border border-slate-300 bg-white text-left font-normal hover:bg-slate-300",
|
||||
!formattedDate && "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setIsOpen(true)}
|
||||
ref={btnRef}>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
<span>{t("common.pick_a_date")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="min-w-96 rounded-lg px-4 py-3">
|
||||
<Calendar
|
||||
value={value}
|
||||
onChange={(date) => onDateChange(date as Date)}
|
||||
minDate={minDate || new Date()}
|
||||
className="!border-0"
|
||||
tileClassName={({ date }: { date: Date }) => {
|
||||
const baseClass =
|
||||
"hover:fb-bg-input-bg-selected fb-rounded-custom fb-h-9 fb-p-0 fb-mt-1 fb-font-normal fb-text-heading aria-selected:fb-opacity-100 focus:fb-ring-2 focus:fb-bg-slate-200";
|
||||
// today's date class
|
||||
if (
|
||||
date.getDate() === new Date().getDate() &&
|
||||
date.getMonth() === new Date().getMonth() &&
|
||||
date.getFullYear() === new Date().getFullYear()
|
||||
) {
|
||||
return `${baseClass} !fb-bg-brand !fb-border-border-highlight !fb-text-heading focus:fb-ring-2 focus:fb-bg-slate-200`;
|
||||
}
|
||||
// active date class
|
||||
if (
|
||||
date.getDate() === value?.getDate() &&
|
||||
date.getMonth() === value?.getMonth() &&
|
||||
date.getFullYear() === value?.getFullYear()
|
||||
) {
|
||||
return `${baseClass} !fb-bg-brand !fb-border-border-highlight !fb-text-heading`;
|
||||
}
|
||||
|
||||
return baseClass;
|
||||
}}
|
||||
showNeighboringMonth={false}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{formattedDate && onClearDate && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearDate}
|
||||
className="h-8 w-8 p-0 hover:bg-slate-200">
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.react-calendar__navigation button {
|
||||
margin: 1% !important ;
|
||||
margin: 1% !important;
|
||||
border-radius: 6%;
|
||||
}
|
||||
|
||||
@@ -23,17 +23,20 @@
|
||||
.react-calendar__month-view__days__day--weekend {
|
||||
color: var(--fb-brand) !important;
|
||||
}
|
||||
|
||||
.react-calendar__tile:disabled {
|
||||
background-color: var(--slate-100) !important;
|
||||
color: var(--slate-400) !important;
|
||||
cursor: not-allowed !important;
|
||||
pointer-events: none !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
.react-calendar__tile:enabled:hover,
|
||||
.react-calendar__tile:enabled:focus {
|
||||
background-color: var(--slate-200) !important;
|
||||
color: var(--slate-900) !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
.react-calendar__tile--now {
|
||||
@@ -49,4 +52,4 @@
|
||||
.react-calendar__tile--hasActive {
|
||||
background-color: var(--fb-brand-color) !important;
|
||||
border-radius: 6% !important;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user