mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-12 16:59:35 -05:00
chore: Simpler Sharing Page [Concept] (#2361)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
+9
-2
@@ -1,6 +1,7 @@
|
||||
import revalidateSurveyIdPath from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { InboxIcon, PresentationIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
|
||||
@@ -17,18 +18,24 @@ export default function SurveyResultsTab({
|
||||
surveyId,
|
||||
responseCount,
|
||||
}: SurveyResultsTabProps) {
|
||||
const params = useParams();
|
||||
const sharingKey = params.sharingKey as string;
|
||||
const isSharingPage = !!sharingKey;
|
||||
|
||||
const url = isSharingPage ? `/share/${sharingKey}` : `/environments/${environmentId}/surveys/${surveyId}`;
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: "summary",
|
||||
label: "Summary",
|
||||
icon: <PresentationIcon className="h-5 w-5" />,
|
||||
href: `/environments/${environmentId}/surveys/${surveyId}/summary?referer=true`,
|
||||
href: `${url}/summary?referer=true`,
|
||||
},
|
||||
{
|
||||
id: "responses",
|
||||
label: `Responses ${responseCount !== null ? `(${responseCount})` : ""}`,
|
||||
icon: <InboxIcon className="h-5 w-5" />,
|
||||
href: `/environments/${environmentId}/surveys/${surveyId}/responses?referer=true`,
|
||||
href: `${url}/responses?referer=true`,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+46
-9
@@ -10,7 +10,11 @@ import ResponseTimeline from "@/app/(app)/environments/[environmentId]/surveys/[
|
||||
import CustomFilter from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||
import SummaryHeader from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SummaryHeader";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import {
|
||||
getResponseCountBySurveySharingKeyAction,
|
||||
getResponsesBySurveySharingKeyAction,
|
||||
} from "@/app/share/[sharingKey]/action";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
|
||||
@@ -31,7 +35,7 @@ interface ResponsePageProps {
|
||||
surveyId: string;
|
||||
webAppUrl: string;
|
||||
product: TProduct;
|
||||
user: TUser;
|
||||
user?: TUser;
|
||||
environmentTags: TTag[];
|
||||
attributes: TSurveyPersonAttributes;
|
||||
responsesPerPage: number;
|
||||
@@ -52,6 +56,10 @@ const ResponsePage = ({
|
||||
membershipRole,
|
||||
totalResponseCount,
|
||||
}: ResponsePageProps) => {
|
||||
const params = useParams();
|
||||
const sharingKey = params.sharingKey as string;
|
||||
const isSharingPage = !!sharingKey;
|
||||
|
||||
const [responseCount, setResponseCount] = useState<number | null>(null);
|
||||
const [responses, setResponses] = useState<TResponse[]>([]);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
@@ -73,13 +81,26 @@ const ResponsePage = ({
|
||||
|
||||
const fetchNextPage = useCallback(async () => {
|
||||
const newPage = page + 1;
|
||||
const newResponses = await getResponsesAction(surveyId, newPage, responsesPerPage, filters);
|
||||
|
||||
let newResponses: TResponse[] = [];
|
||||
|
||||
if (isSharingPage) {
|
||||
newResponses = await getResponsesBySurveySharingKeyAction(
|
||||
sharingKey,
|
||||
newPage,
|
||||
responsesPerPage,
|
||||
filters
|
||||
);
|
||||
} else {
|
||||
newResponses = await getResponsesAction(surveyId, newPage, responsesPerPage, filters);
|
||||
}
|
||||
|
||||
if (newResponses.length === 0 || newResponses.length < responsesPerPage) {
|
||||
setHasMore(false);
|
||||
}
|
||||
setResponses([...responses, ...newResponses]);
|
||||
setPage(newPage);
|
||||
}, [filters, page, responses, responsesPerPage, surveyId]);
|
||||
}, [filters, isSharingPage, page, responses, responsesPerPage, sharingKey, surveyId]);
|
||||
|
||||
const deleteResponse = (responseId: string) => {
|
||||
setResponses(responses.filter((response) => response.id !== responseId));
|
||||
@@ -100,17 +121,32 @@ const ResponsePage = ({
|
||||
|
||||
useEffect(() => {
|
||||
const handleResponsesCount = async () => {
|
||||
const responseCount = await getResponseCountAction(surveyId, filters);
|
||||
let responseCount = 0;
|
||||
|
||||
if (isSharingPage) {
|
||||
responseCount = await getResponseCountBySurveySharingKeyAction(sharingKey, filters);
|
||||
} else {
|
||||
responseCount = await getResponseCountAction(surveyId, filters);
|
||||
}
|
||||
|
||||
setResponseCount(responseCount);
|
||||
};
|
||||
handleResponsesCount();
|
||||
}, [filters, surveyId]);
|
||||
}, [filters, isSharingPage, sharingKey, surveyId]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchInitialResponses = async () => {
|
||||
try {
|
||||
setFetchingFirstPage(true);
|
||||
const responses = await getResponsesAction(surveyId, 1, responsesPerPage, filters);
|
||||
|
||||
let responses: TResponse[] = [];
|
||||
|
||||
if (isSharingPage) {
|
||||
responses = await getResponsesBySurveySharingKeyAction(sharingKey, 1, responsesPerPage, filters);
|
||||
} else {
|
||||
responses = await getResponsesAction(surveyId, 1, responsesPerPage, filters);
|
||||
}
|
||||
|
||||
if (responses.length < responsesPerPage) {
|
||||
setHasMore(false);
|
||||
}
|
||||
@@ -120,7 +156,7 @@ const ResponsePage = ({
|
||||
}
|
||||
};
|
||||
fetchInitialResponses();
|
||||
}, [surveyId, filters, responsesPerPage]);
|
||||
}, [surveyId, filters, responsesPerPage, sharingKey, isSharingPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
@@ -141,7 +177,7 @@ const ResponsePage = ({
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
<CustomFilter environmentTags={environmentTags} attributes={attributes} survey={survey} />
|
||||
<ResultsShareButton survey={survey} webAppUrl={webAppUrl} user={user} />
|
||||
{!isSharingPage && <ResultsShareButton survey={survey} webAppUrl={webAppUrl} user={user} />}
|
||||
</div>
|
||||
<SurveyResultsTabs
|
||||
activeId="responses"
|
||||
@@ -163,6 +199,7 @@ const ResponsePage = ({
|
||||
isFetchingFirstPage={isFetchingFirstPage}
|
||||
responseCount={responseCount}
|
||||
totalResponseCount={totalResponseCount}
|
||||
isSharingPage={isSharingPage}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
);
|
||||
|
||||
+16
-5
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { EmptyInAppSurveys } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole";
|
||||
import { getMembershipByUserIdTeamIdAction } from "@formbricks/lib/membership/hooks/actions";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
@@ -19,7 +19,7 @@ interface ResponseTimelineProps {
|
||||
surveyId: string;
|
||||
responses: TResponse[];
|
||||
survey: TSurvey;
|
||||
user: TUser;
|
||||
user?: TUser;
|
||||
environmentTags: TTag[];
|
||||
fetchNextPage: () => void;
|
||||
hasMore: boolean;
|
||||
@@ -28,6 +28,7 @@ interface ResponseTimelineProps {
|
||||
isFetchingFirstPage: boolean;
|
||||
responseCount: number | null;
|
||||
totalResponseCount: number;
|
||||
isSharingPage?: boolean;
|
||||
}
|
||||
|
||||
export default function ResponseTimeline({
|
||||
@@ -43,7 +44,9 @@ export default function ResponseTimeline({
|
||||
isFetchingFirstPage,
|
||||
responseCount,
|
||||
totalResponseCount,
|
||||
isSharingPage = false,
|
||||
}: ResponseTimelineProps) {
|
||||
const [isViewer, setIsViewer] = useState(false);
|
||||
const loadingRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -69,8 +72,16 @@ export default function ResponseTimeline({
|
||||
};
|
||||
}, [fetchNextPage, hasMore]);
|
||||
|
||||
const { membershipRole } = useMembershipRole(survey.environmentId);
|
||||
const { isViewer } = getAccessFlags(membershipRole);
|
||||
useEffect(() => {
|
||||
const getRole = async () => {
|
||||
if (isSharingPage) return setIsViewer(true);
|
||||
|
||||
const membershipRole = await getMembershipByUserIdTeamIdAction(survey.environmentId);
|
||||
const { isViewer } = getAccessFlags(membershipRole);
|
||||
setIsViewer(isViewer);
|
||||
};
|
||||
getRole();
|
||||
}, [survey.environmentId, isSharingPage]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
+27
-7
@@ -12,7 +12,11 @@ import SummaryMetadata from "@/app/(app)/environments/[environmentId]/surveys/[s
|
||||
import CustomFilter from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||
import SummaryHeader from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SummaryHeader";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import {
|
||||
getResponseCountBySurveySharingKeyAction,
|
||||
getSummaryBySurveySharingKeyAction,
|
||||
} from "@/app/share/[sharingKey]/action";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
|
||||
@@ -48,7 +52,7 @@ interface SummaryPageProps {
|
||||
surveyId: string;
|
||||
webAppUrl: string;
|
||||
product: TProduct;
|
||||
user: TUser;
|
||||
user?: TUser;
|
||||
environmentTags: TTag[];
|
||||
attributes: TSurveyPersonAttributes;
|
||||
membershipRole?: TMembershipRole;
|
||||
@@ -59,14 +63,18 @@ const SummaryPage = ({
|
||||
environment,
|
||||
survey,
|
||||
surveyId,
|
||||
webAppUrl,
|
||||
product,
|
||||
webAppUrl,
|
||||
user,
|
||||
environmentTags,
|
||||
attributes,
|
||||
membershipRole,
|
||||
totalResponseCount,
|
||||
}: SummaryPageProps) => {
|
||||
const params = useParams();
|
||||
const sharingKey = params.sharingKey as string;
|
||||
const isSharingPage = !!sharingKey;
|
||||
|
||||
const [responseCount, setResponseCount] = useState<number | null>(null);
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(initialSurveySummary);
|
||||
@@ -82,13 +90,25 @@ const SummaryPage = ({
|
||||
const handleInitialData = async () => {
|
||||
try {
|
||||
setFetchingSummary(true);
|
||||
const responseCount = await getResponseCountAction(surveyId, filters);
|
||||
let responseCount;
|
||||
if (isSharingPage) {
|
||||
responseCount = await getResponseCountBySurveySharingKeyAction(sharingKey, filters);
|
||||
} else {
|
||||
responseCount = await getResponseCountAction(surveyId, filters);
|
||||
}
|
||||
setResponseCount(responseCount);
|
||||
if (responseCount === 0) {
|
||||
setSurveySummary(initialSurveySummary);
|
||||
return;
|
||||
}
|
||||
const response = await getSurveySummaryAction(surveyId, filters);
|
||||
|
||||
let response;
|
||||
if (isSharingPage) {
|
||||
response = await getSummaryBySurveySharingKeyAction(sharingKey, filters);
|
||||
} else {
|
||||
response = await getSurveySummaryAction(surveyId, filters);
|
||||
}
|
||||
|
||||
setSurveySummary(response);
|
||||
} finally {
|
||||
setFetchingSummary(false);
|
||||
@@ -96,7 +116,7 @@ const SummaryPage = ({
|
||||
};
|
||||
|
||||
handleInitialData();
|
||||
}, [filters, surveyId]);
|
||||
}, [filters, isSharingPage, sharingKey, surveyId]);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -123,7 +143,7 @@ const SummaryPage = ({
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
<CustomFilter environmentTags={environmentTags} attributes={attributes} survey={survey} />
|
||||
<ResultsShareButton survey={survey} webAppUrl={webAppUrl} user={user} />
|
||||
{!isSharingPage && <ResultsShareButton survey={survey} webAppUrl={webAppUrl} user={user} />}
|
||||
</div>
|
||||
<SurveyResultsTabs
|
||||
activeId="summary"
|
||||
|
||||
+55
-48
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/app/lib/surveys/surveys";
|
||||
import { differenceInDays, format, startOfDay, subDays } from "date-fns";
|
||||
import { ChevronDown, ChevronUp, DownloadIcon } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
@@ -64,6 +65,9 @@ const getDifferenceOfDays = (from, to) => {
|
||||
};
|
||||
|
||||
const CustomFilter = ({ environmentTags, attributes, survey }: CustomFilterProps) => {
|
||||
const params = useParams();
|
||||
const isSharingPage = !!params.sharingKey;
|
||||
|
||||
const { selectedFilter, setSelectedOptions, dateRange, setDateRange, resetState } = useResponseFilter();
|
||||
const [filterRange, setFilterRange] = useState<FilterDropDownLabels>(
|
||||
dateRange.from && dateRange.to
|
||||
@@ -264,55 +268,58 @@ const CustomFilter = ({ environmentTags, attributes, survey }: CustomFilterProps
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
setIsDownloadDropDownOpen(value);
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
|
||||
<div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:min-w-[11rem] sm:px-6 sm:py-3">
|
||||
<div className="hidden w-full items-center justify-between sm:flex">
|
||||
<span className="text-sm text-slate-700">Download</span>
|
||||
{isDownloadDropDownOpen ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
{!isSharingPage && (
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
setIsDownloadDropDownOpen(value);
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
|
||||
<div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:min-w-[11rem] sm:px-6 sm:py-3">
|
||||
<div className="hidden w-full items-center justify-between sm:flex">
|
||||
<span className="text-sm text-slate-700">Download</span>
|
||||
{isDownloadDropDownOpen ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
<DownloadIcon className="block h-4 sm:hidden" />
|
||||
</div>
|
||||
<DownloadIcon className="block h-4 sm:hidden" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
className="hover:ring-0"
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.ALL, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">All responses (CSV)</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="hover:ring-0"
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.ALL, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">All responses (Excel)</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="hover:ring-0"
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.FILTER, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">Current selection (CSV)</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="hover:ring-0"
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.FILTER, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">Current selection (Excel)</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
className="hover:ring-0"
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.ALL, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">All responses (CSV)</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="hover:ring-0"
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.ALL, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">All responses (Excel)</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="hover:ring-0"
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.FILTER, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">Current selection (CSV)</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="hover:ring-0"
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.FILTER, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">Current selection (Excel)</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
{isDatePickerOpen && (
|
||||
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">
|
||||
|
||||
+2
-2
@@ -25,7 +25,7 @@ import ShareSurveyResults from "../(analysis)/summary/components/ShareSurveyResu
|
||||
interface ResultsShareButtonProps {
|
||||
survey: TSurvey;
|
||||
webAppUrl: string;
|
||||
user: TUser;
|
||||
user?: TUser;
|
||||
}
|
||||
|
||||
export default function ResultsShareButton({ survey, webAppUrl, user }: ResultsShareButtonProps) {
|
||||
@@ -138,7 +138,7 @@ export default function ResultsShareButton({ survey, webAppUrl, user }: ResultsS
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{showLinkModal && (
|
||||
{showLinkModal && user && (
|
||||
<ShareEmbedSurvey
|
||||
survey={survey}
|
||||
open={showLinkModal}
|
||||
|
||||
+138
-127
@@ -5,7 +5,7 @@ import ResultsShareButton from "@/app/(app)/environments/[environmentId]/surveys
|
||||
import SurveyStatusDropdown from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||
import { updateSurveyAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions";
|
||||
import { CircleEllipsisIcon, ShareIcon, SquarePenIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
@@ -39,7 +39,7 @@ interface SummaryHeaderProps {
|
||||
survey: TSurvey;
|
||||
webAppUrl: string;
|
||||
product: TProduct;
|
||||
user: TUser;
|
||||
user?: TUser;
|
||||
membershipRole?: TMembershipRole;
|
||||
}
|
||||
const SummaryHeader = ({
|
||||
@@ -51,6 +51,10 @@ const SummaryHeader = ({
|
||||
user,
|
||||
membershipRole,
|
||||
}: SummaryHeaderProps) => {
|
||||
const params = useParams();
|
||||
const sharingKey = params.sharingKey as string;
|
||||
const isSharingPage = !!sharingKey;
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const isCloseOnDateEnabled = survey.closeOnDate !== null;
|
||||
@@ -64,135 +68,142 @@ const SummaryHeader = ({
|
||||
<div>
|
||||
<div className="flex gap-4">
|
||||
<p className="text-3xl font-bold text-slate-800">{survey.name}</p>
|
||||
{survey.resultShareKey && <Badge text="Results are public" type="warning" size="normal"></Badge>}
|
||||
{survey.resultShareKey && !isSharingPage && (
|
||||
<Badge text="Results are public" type="warning" size="normal"></Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-base font-extralight text-slate-600">{product.name}</span>
|
||||
</div>
|
||||
<div className="hidden justify-end gap-x-1.5 sm:flex">
|
||||
{/* <ResultsShareButton survey={survey} webAppUrl={webAppUrl} product={product} user={user} /> */}
|
||||
{!isViewer &&
|
||||
(environment?.widgetSetupCompleted || survey.type === "link") &&
|
||||
survey?.status !== "draft" ? (
|
||||
<SurveyStatusDropdown environment={environment} survey={survey} />
|
||||
) : null}
|
||||
{survey.type === "link" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowShareSurveyModal(true);
|
||||
}}>
|
||||
<ShareIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
{!isViewer && (
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
className="h-full w-full px-3 lg:px-6"
|
||||
href={`/environments/${environment.id}/surveys/${surveyId}/edit`}>
|
||||
Edit
|
||||
<SquarePenIcon className="ml-1 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="block sm:hidden">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="secondary" className="h-full w-full rounded-md p-2">
|
||||
<CircleEllipsisIcon className="h-6" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="p-2">
|
||||
{survey.type === "link" && (
|
||||
<>
|
||||
<ResultsShareButton survey={survey} webAppUrl={webAppUrl} user={user} />
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{(environment?.widgetSetupCompleted || survey.type === "link") && survey?.status !== "draft" ? (
|
||||
<>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
disabled={isStatusChangeDisabled}
|
||||
style={isStatusChangeDisabled ? { pointerEvents: "none", opacity: 0.5 } : {}}>
|
||||
<div className="flex items-center">
|
||||
{(survey.type === "link" || environment.widgetSetupCompleted) && (
|
||||
<SurveyStatusIndicator status={survey.status} />
|
||||
)}
|
||||
<span className="ml-1 text-sm text-slate-700">
|
||||
{survey.status === "scheduled" && "Scheduled"}
|
||||
{survey.status === "inProgress" && "In-progress"}
|
||||
{survey.status === "paused" && "Paused"}
|
||||
{survey.status === "completed" && "Completed"}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup
|
||||
value={survey.status}
|
||||
onValueChange={(value) => {
|
||||
const castedValue = value as "draft" | "inProgress" | "paused" | "completed";
|
||||
updateSurveyAction({ ...survey, status: castedValue })
|
||||
.then(() => {
|
||||
toast.success(
|
||||
value === "inProgress"
|
||||
? "Survey live"
|
||||
: value === "paused"
|
||||
? "Survey paused"
|
||||
: value === "completed"
|
||||
? "Survey completed"
|
||||
: ""
|
||||
);
|
||||
router.refresh();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
});
|
||||
}}>
|
||||
<DropdownMenuRadioItem
|
||||
value="inProgress"
|
||||
className="cursor-pointer break-all text-slate-600">
|
||||
In-progress
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioItem
|
||||
value="paused"
|
||||
className="cursor-pointer break-all text-slate-600">
|
||||
Paused
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioItem
|
||||
value="completed"
|
||||
className="cursor-pointer break-all text-slate-600">
|
||||
Completed
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
{!isSharingPage && (
|
||||
<>
|
||||
<div className="hidden justify-end gap-x-1.5 sm:flex">
|
||||
{!isViewer &&
|
||||
(environment.widgetSetupCompleted || survey.type === "link") &&
|
||||
survey.status !== "draft" ? (
|
||||
<SurveyStatusDropdown environment={environment} survey={survey} />
|
||||
) : null}
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
size="sm"
|
||||
className="flex h-full w-full justify-center px-3 lg:px-6"
|
||||
href={`/environments/${environment.id}/surveys/${surveyId}/edit`}>
|
||||
Edit
|
||||
<SquarePenIcon className="ml-1 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<SuccessMessage environment={environment} survey={survey} webAppUrl={webAppUrl} user={user} />
|
||||
{showShareSurveyModal && (
|
||||
<ShareEmbedSurvey
|
||||
survey={survey}
|
||||
open={showShareSurveyModal}
|
||||
setOpen={setShowShareSurveyModal}
|
||||
webAppUrl={webAppUrl}
|
||||
user={user}
|
||||
/>
|
||||
{survey.type === "link" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowShareSurveyModal(true);
|
||||
}}>
|
||||
<ShareIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
{!isViewer && (
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
className="h-full w-full px-3 lg:px-6"
|
||||
href={`/environments/${environment.id}/surveys/${surveyId}/edit`}>
|
||||
Edit
|
||||
<SquarePenIcon className="ml-1 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="block sm:hidden">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="secondary" className="h-full w-full rounded-md p-2">
|
||||
<CircleEllipsisIcon className="h-6" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="p-2">
|
||||
{survey.type === "link" && user && (
|
||||
<>
|
||||
<ResultsShareButton survey={survey} webAppUrl={webAppUrl} user={user} />
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{(environment?.widgetSetupCompleted || survey.type === "link") &&
|
||||
survey?.status !== "draft" ? (
|
||||
<>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
disabled={isStatusChangeDisabled}
|
||||
style={isStatusChangeDisabled ? { pointerEvents: "none", opacity: 0.5 } : {}}>
|
||||
<div className="flex items-center">
|
||||
{(survey.type === "link" || environment.widgetSetupCompleted) && (
|
||||
<SurveyStatusIndicator status={survey.status} />
|
||||
)}
|
||||
<span className="ml-1 text-sm text-slate-700">
|
||||
{survey.status === "inProgress" && "In-progress"}
|
||||
{survey.status === "paused" && "Paused"}
|
||||
{survey.status === "completed" && "Completed"}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup
|
||||
value={survey.status}
|
||||
onValueChange={(value) => {
|
||||
const castedValue = value as "draft" | "inProgress" | "paused" | "completed";
|
||||
updateSurveyAction({ ...survey, status: castedValue })
|
||||
.then(() => {
|
||||
toast.success(
|
||||
value === "inProgress"
|
||||
? "Survey live"
|
||||
: value === "paused"
|
||||
? "Survey paused"
|
||||
: value === "completed"
|
||||
? "Survey completed"
|
||||
: ""
|
||||
);
|
||||
router.refresh();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
});
|
||||
}}>
|
||||
<DropdownMenuRadioItem
|
||||
value="inProgress"
|
||||
className="cursor-pointer break-all text-slate-600">
|
||||
In-progress
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioItem
|
||||
value="paused"
|
||||
className="cursor-pointer break-all text-slate-600">
|
||||
Paused
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioItem
|
||||
value="completed"
|
||||
className="cursor-pointer break-all text-slate-600">
|
||||
Completed
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
size="sm"
|
||||
className="flex h-full w-full justify-center px-3 lg:px-6"
|
||||
href={`/environments/${environment.id}/surveys/${surveyId}/edit`}>
|
||||
Edit
|
||||
<SquarePenIcon className="ml-1 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{user && (
|
||||
<SuccessMessage environment={environment} survey={survey} webAppUrl={webAppUrl} user={user} />
|
||||
)}
|
||||
{showShareSurveyModal && user && (
|
||||
<ShareEmbedSurvey
|
||||
survey={survey}
|
||||
open={showShareSurveyModal}
|
||||
setOpen={setShowShareSurveyModal}
|
||||
webAppUrl={webAppUrl}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import revalidateSurveyIdPath from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { InboxIcon, PresentationIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
|
||||
interface SurveyResultsTabProps {
|
||||
activeId: string;
|
||||
environmentId: string;
|
||||
surveyId: string;
|
||||
responseCount: number | null;
|
||||
sharingKey: string;
|
||||
}
|
||||
|
||||
export default function SurveyResultsTab({
|
||||
activeId,
|
||||
environmentId,
|
||||
surveyId,
|
||||
responseCount,
|
||||
sharingKey,
|
||||
}: SurveyResultsTabProps) {
|
||||
const tabs = [
|
||||
{
|
||||
id: "summary",
|
||||
label: "Summary",
|
||||
icon: <PresentationIcon className="h-5 w-5" />,
|
||||
href: `/share/${sharingKey}/summary?referer=true`,
|
||||
},
|
||||
{
|
||||
id: "responses",
|
||||
label: `Responses ${responseCount !== null ? `(${responseCount})` : ""}`,
|
||||
icon: <InboxIcon className="h-5 w-5" />,
|
||||
href: `/share/${sharingKey}/responses?referer=true`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-7 h-14 w-full border-b">
|
||||
<nav className="flex h-full items-center space-x-4 justify-self-center" aria-label="Tabs">
|
||||
{tabs.map((tab) => (
|
||||
<Link
|
||||
key={tab.id}
|
||||
onClick={() => {
|
||||
revalidateSurveyIdPath(environmentId, surveyId);
|
||||
}}
|
||||
href={tab.href}
|
||||
className={cn(
|
||||
tab.id === activeId
|
||||
? " border-brand-dark text-brand-dark border-b-2 font-semibold"
|
||||
: "text-slate-500 hover:text-slate-700",
|
||||
"flex h-full items-center px-3 text-sm font-medium"
|
||||
)}
|
||||
aria-current={tab.id === activeId ? "page" : undefined}>
|
||||
{tab.icon && <div className="mr-2 h-5 w-5">{tab.icon}</div>}
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { createTag } from "@formbricks/lib/tag/service";
|
||||
import { canUserAccessTagOnResponse } from "@formbricks/lib/tagOnResponse/auth";
|
||||
import { addTagToRespone, deleteTagOnResponse } from "@formbricks/lib/tagOnResponse/service";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
|
||||
export const createTagAction = async (environmentId: string, tagName: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await createTag(environmentId, tagName);
|
||||
};
|
||||
|
||||
export const createTagToResponeAction = async (responseId: string, tagId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessTagOnResponse(session.user.id, tagId, responseId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await addTagToRespone(responseId, tagId);
|
||||
};
|
||||
|
||||
export const deleteTagOnResponseAction = async (responseId: string, tagId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessTagOnResponse(session.user.id, tagId, responseId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await deleteTagOnResponse(responseId, tagId);
|
||||
};
|
||||
@@ -1,144 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import SurveyResultsTabs from "@/app/share/[sharingKey]/(analysis)/components/SurveyResultsTabs";
|
||||
import ResponseTimeline from "@/app/share/[sharingKey]/(analysis)/responses/components/ResponseTimeline";
|
||||
import {
|
||||
getResponseCountBySurveySharingKeyAction,
|
||||
getResponsesBySurveySharingKeyAction,
|
||||
} from "@/app/share/[sharingKey]/action";
|
||||
import CustomFilter from "@/app/share/[sharingKey]/components/CustomFilter";
|
||||
import SummaryHeader from "@/app/share/[sharingKey]/components/SummaryHeader";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TResponse, TSurveyPersonAttributes } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import ContentWrapper from "@formbricks/ui/ContentWrapper";
|
||||
|
||||
interface ResponsePageProps {
|
||||
environment: TEnvironment;
|
||||
survey: TSurvey;
|
||||
surveyId: string;
|
||||
webAppUrl: string;
|
||||
product: TProduct;
|
||||
sharingKey: string;
|
||||
environmentTags: TTag[];
|
||||
attributes: TSurveyPersonAttributes;
|
||||
responsesPerPage: number;
|
||||
totalResponseCount: number;
|
||||
}
|
||||
|
||||
const ResponsePage = ({
|
||||
environment,
|
||||
survey,
|
||||
surveyId,
|
||||
product,
|
||||
sharingKey,
|
||||
environmentTags,
|
||||
attributes,
|
||||
responsesPerPage,
|
||||
totalResponseCount,
|
||||
}: ResponsePageProps) => {
|
||||
const [responses, setResponses] = useState<TResponse[]>([]);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||
const [responseCount, setResponseCount] = useState<number | null>(null);
|
||||
const [isFetchingFirstPage, setFetchingFirstPage] = useState<boolean>(true);
|
||||
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
const filters = useMemo(
|
||||
() => getFormattedFilters(survey, selectedFilter, dateRange),
|
||||
[survey, selectedFilter, dateRange]
|
||||
);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
survey = useMemo(() => {
|
||||
return checkForRecallInHeadline(survey, "default");
|
||||
}, [survey]);
|
||||
|
||||
const fetchNextPage = useCallback(async () => {
|
||||
const newPage = page + 1;
|
||||
const newResponses = await getResponsesBySurveySharingKeyAction(
|
||||
sharingKey,
|
||||
newPage,
|
||||
responsesPerPage,
|
||||
filters
|
||||
);
|
||||
if (newResponses.length === 0 || newResponses.length < responsesPerPage) {
|
||||
setHasMore(false);
|
||||
}
|
||||
setResponses([...responses, ...newResponses]);
|
||||
setPage(newPage);
|
||||
}, [filters, page, responses, responsesPerPage, sharingKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchParams?.get("referer")) {
|
||||
resetState();
|
||||
}
|
||||
}, [searchParams, resetState]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResponsesCount = async () => {
|
||||
const responseCount = await getResponseCountBySurveySharingKeyAction(sharingKey, filters);
|
||||
setResponseCount(responseCount);
|
||||
};
|
||||
handleResponsesCount();
|
||||
}, [filters, sharingKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchInitialResponses = async () => {
|
||||
try {
|
||||
setFetchingFirstPage(true);
|
||||
const responses = await getResponsesBySurveySharingKeyAction(
|
||||
sharingKey,
|
||||
1,
|
||||
responsesPerPage,
|
||||
filters
|
||||
);
|
||||
if (responses.length < responsesPerPage) {
|
||||
setHasMore(false);
|
||||
}
|
||||
setResponses(responses);
|
||||
} finally {
|
||||
setFetchingFirstPage(false);
|
||||
}
|
||||
};
|
||||
fetchInitialResponses();
|
||||
}, [filters, responsesPerPage, sharingKey]);
|
||||
|
||||
return (
|
||||
<ContentWrapper>
|
||||
<SummaryHeader survey={survey} product={product} />
|
||||
<CustomFilter environmentTags={environmentTags} attributes={attributes} survey={survey} />
|
||||
<SurveyResultsTabs
|
||||
activeId="responses"
|
||||
environmentId={environment.id}
|
||||
surveyId={surveyId}
|
||||
responseCount={responseCount}
|
||||
sharingKey={sharingKey}
|
||||
/>
|
||||
<ResponseTimeline
|
||||
environment={environment}
|
||||
surveyId={surveyId}
|
||||
responses={responses}
|
||||
survey={survey}
|
||||
environmentTags={environmentTags}
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasMore={hasMore}
|
||||
isFetchingFirstPage={isFetchingFirstPage}
|
||||
responseCount={responseCount}
|
||||
totalResponseCount={totalResponseCount}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponsePage;
|
||||
@@ -1,103 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { EmptyInAppSurveys } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import EmptySpaceFiller from "@formbricks/ui/EmptySpaceFiller";
|
||||
import SingleResponseCard from "@formbricks/ui/SingleResponseCard";
|
||||
import { SkeletonLoader } from "@formbricks/ui/SkeletonLoader";
|
||||
|
||||
interface ResponseTimelineProps {
|
||||
environment: TEnvironment;
|
||||
surveyId: string;
|
||||
responses: TResponse[];
|
||||
survey: TSurvey;
|
||||
environmentTags: TTag[];
|
||||
fetchNextPage: () => void;
|
||||
hasMore: boolean;
|
||||
isFetchingFirstPage: boolean;
|
||||
responseCount: number | null;
|
||||
totalResponseCount: number;
|
||||
}
|
||||
|
||||
export default function ResponseTimeline({
|
||||
environment,
|
||||
responses,
|
||||
survey,
|
||||
environmentTags,
|
||||
fetchNextPage,
|
||||
hasMore,
|
||||
isFetchingFirstPage,
|
||||
responseCount,
|
||||
totalResponseCount,
|
||||
}: ResponseTimelineProps) {
|
||||
const loadingRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const currentLoadingRef = loadingRef.current;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
if (hasMore) fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.8 }
|
||||
);
|
||||
|
||||
if (currentLoadingRef) {
|
||||
observer.observe(currentLoadingRef);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (currentLoadingRef) {
|
||||
observer.unobserve(currentLoadingRef);
|
||||
}
|
||||
};
|
||||
}, [fetchNextPage, hasMore]);
|
||||
|
||||
const { membershipRole } = useMembershipRole(survey.environmentId);
|
||||
const { isViewer } = getAccessFlags(membershipRole);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{survey.type === "web" && responses.length === 0 && !environment.widgetSetupCompleted ? (
|
||||
<EmptyInAppSurveys environment={environment} />
|
||||
) : isFetchingFirstPage ? (
|
||||
<SkeletonLoader type="response" />
|
||||
) : responseCount === 0 ? (
|
||||
<EmptySpaceFiller
|
||||
type="response"
|
||||
environment={environment}
|
||||
noWidgetRequired={survey.type === "link"}
|
||||
emptyMessage={totalResponseCount === 0 ? undefined : "No response matches your filter"}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
{responses.map((response) => {
|
||||
return (
|
||||
<div key={response.id}>
|
||||
<SingleResponseCard
|
||||
survey={survey}
|
||||
response={response}
|
||||
user={undefined}
|
||||
environmentTags={environmentTags}
|
||||
pageType="response"
|
||||
environment={environment}
|
||||
isViewer={isViewer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={loadingRef}></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
import ResponsePage from "@/app/share/[sharingKey]/(analysis)/responses/components/ResponsePage";
|
||||
import ResponsePage from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { RESPONSES_PER_PAGE, REVALIDATION_INTERVAL, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { RESPONSES_PER_PAGE, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getResponseCountBySurveyId, getResponsePersonAttributes } from "@formbricks/lib/response/service";
|
||||
import { getSurvey, getSurveyIdByResultShareKey } from "@formbricks/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
|
||||
|
||||
export const revalidate = REVALIDATION_INTERVAL;
|
||||
|
||||
export default async function Page({ params }) {
|
||||
const surveyId = await getSurveyIdByResultShareKey(params.sharingKey);
|
||||
|
||||
@@ -45,7 +43,6 @@ export default async function Page({ params }) {
|
||||
surveyId={surveyId}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
product={product}
|
||||
sharingKey={params.sharingKey}
|
||||
environmentTags={tags}
|
||||
attributes={attributes}
|
||||
responsesPerPage={RESPONSES_PER_PAGE}
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import SummaryDropOffs from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
|
||||
import SummaryList from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList";
|
||||
import SummaryMetadata from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import SurveyResultsTabs from "@/app/share/[sharingKey]/(analysis)/components/SurveyResultsTabs";
|
||||
import {
|
||||
getResponseCountBySurveySharingKeyAction,
|
||||
getSummaryBySurveySharingKeyAction,
|
||||
} from "@/app/share/[sharingKey]/action";
|
||||
import CustomFilter from "@/app/share/[sharingKey]/components/CustomFilter";
|
||||
import SummaryHeader from "@/app/share/[sharingKey]/components/SummaryHeader";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSurveyPersonAttributes, TSurveySummary } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import ContentWrapper from "@formbricks/ui/ContentWrapper";
|
||||
|
||||
const initialSurveySummary: TSurveySummary = {
|
||||
meta: {
|
||||
completedPercentage: 0,
|
||||
completedResponses: 0,
|
||||
displayCount: 0,
|
||||
dropOffPercentage: 0,
|
||||
dropOffCount: 0,
|
||||
startsPercentage: 0,
|
||||
totalResponses: 0,
|
||||
ttcAverage: 0,
|
||||
},
|
||||
dropOff: [],
|
||||
summary: [],
|
||||
};
|
||||
|
||||
interface SummaryPageProps {
|
||||
environment: TEnvironment;
|
||||
survey: TSurvey;
|
||||
surveyId: string;
|
||||
product: TProduct;
|
||||
sharingKey: string;
|
||||
environmentTags: TTag[];
|
||||
attributes: TSurveyPersonAttributes;
|
||||
totalResponseCount: number;
|
||||
}
|
||||
|
||||
const SummaryPage = ({
|
||||
environment,
|
||||
survey,
|
||||
surveyId,
|
||||
product,
|
||||
sharingKey,
|
||||
environmentTags,
|
||||
attributes,
|
||||
totalResponseCount,
|
||||
}: SummaryPageProps) => {
|
||||
const [responseCount, setResponseCount] = useState<number | null>(null);
|
||||
|
||||
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(initialSurveySummary);
|
||||
const [showDropOffs, setShowDropOffs] = useState<boolean>(false);
|
||||
const [isFetchingSummary, setFetchingSummary] = useState<boolean>(true);
|
||||
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
const filters = useMemo(
|
||||
() => getFormattedFilters(survey, selectedFilter, dateRange),
|
||||
[survey, selectedFilter, dateRange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleInitialData = async () => {
|
||||
try {
|
||||
setFetchingSummary(true);
|
||||
const responseCount = await getResponseCountBySurveySharingKeyAction(sharingKey, filters);
|
||||
setResponseCount(responseCount);
|
||||
if (responseCount === 0) {
|
||||
setSurveySummary(initialSurveySummary);
|
||||
return;
|
||||
}
|
||||
const response = await getSummaryBySurveySharingKeyAction(sharingKey, filters);
|
||||
setSurveySummary(response);
|
||||
} finally {
|
||||
setFetchingSummary(false);
|
||||
}
|
||||
};
|
||||
|
||||
handleInitialData();
|
||||
}, [filters, sharingKey]);
|
||||
|
||||
survey = useMemo(() => {
|
||||
return checkForRecallInHeadline(survey, "default");
|
||||
}, [survey]);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchParams?.get("referer")) {
|
||||
resetState();
|
||||
}
|
||||
}, [searchParams, resetState]);
|
||||
|
||||
return (
|
||||
<ContentWrapper>
|
||||
<SummaryHeader survey={survey} product={product} />
|
||||
<CustomFilter environmentTags={environmentTags} attributes={attributes} survey={survey} />
|
||||
<SurveyResultsTabs
|
||||
activeId="summary"
|
||||
environmentId={environment.id}
|
||||
surveyId={surveyId}
|
||||
responseCount={responseCount}
|
||||
sharingKey={sharingKey}
|
||||
/>
|
||||
<SummaryMetadata
|
||||
survey={survey}
|
||||
surveySummary={surveySummary.meta}
|
||||
showDropOffs={showDropOffs}
|
||||
setShowDropOffs={setShowDropOffs}
|
||||
/>
|
||||
{showDropOffs && <SummaryDropOffs dropOff={surveySummary.dropOff} />}
|
||||
|
||||
<SummaryList
|
||||
summary={surveySummary.summary}
|
||||
responseCount={responseCount}
|
||||
survey={survey}
|
||||
environment={environment}
|
||||
fetchingSummary={isFetchingSummary}
|
||||
totalResponseCount={totalResponseCount}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default SummaryPage;
|
||||
@@ -1,15 +1,13 @@
|
||||
import SummaryPage from "@/app/share/[sharingKey]/(analysis)/summary/components/SummaryPage";
|
||||
import SummaryPage from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getResponseCountBySurveyId, getResponsePersonAttributes } from "@formbricks/lib/response/service";
|
||||
import { getSurvey, getSurveyIdByResultShareKey } from "@formbricks/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
|
||||
|
||||
export const revalidate = REVALIDATION_INTERVAL;
|
||||
|
||||
export default async function Page({ params }) {
|
||||
const surveyId = await getSurveyIdByResultShareKey(params.sharingKey);
|
||||
|
||||
@@ -43,7 +41,7 @@ export default async function Page({ params }) {
|
||||
environment={environment}
|
||||
survey={survey}
|
||||
surveyId={survey.id}
|
||||
sharingKey={params.sharingKey}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
product={product}
|
||||
environmentTags={tags}
|
||||
attributes={attributes}
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DateRange,
|
||||
useResponseFilter,
|
||||
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import ResponseFilter from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
|
||||
import { generateQuestionAndFilterOptions, getTodayDate } from "@/app/lib/surveys/surveys";
|
||||
import { differenceInDays, format, startOfDay, subDays } from "date-fns";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useClickOutside } from "@formbricks/lib/utils/hooks/useClickOutside";
|
||||
import { TSurveyPersonAttributes } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { Calendar } from "@formbricks/ui/Calendar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
|
||||
enum DateSelected {
|
||||
FROM = "from",
|
||||
TO = "to",
|
||||
}
|
||||
|
||||
enum FilterDropDownLabels {
|
||||
ALL_TIME = "All time",
|
||||
LAST_7_DAYS = "Last 7 days",
|
||||
LAST_30_DAYS = "Last 30 days",
|
||||
CUSTOM_RANGE = "Custom range...",
|
||||
}
|
||||
|
||||
interface CustomFilterProps {
|
||||
environmentTags: TTag[];
|
||||
attributes: TSurveyPersonAttributes;
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
const getDifferenceOfDays = (from, to) => {
|
||||
const days = differenceInDays(to, from);
|
||||
if (days === 7) {
|
||||
return FilterDropDownLabels.LAST_7_DAYS;
|
||||
} else if (days === 30) {
|
||||
return FilterDropDownLabels.LAST_30_DAYS;
|
||||
} else {
|
||||
return FilterDropDownLabels.CUSTOM_RANGE;
|
||||
}
|
||||
};
|
||||
|
||||
const CustomFilter = ({ environmentTags, attributes, survey }: CustomFilterProps) => {
|
||||
const { setSelectedOptions, dateRange, setDateRange } = useResponseFilter();
|
||||
const [filterRange, setFilterRange] = useState<FilterDropDownLabels>(
|
||||
dateRange.from && dateRange.to
|
||||
? getDifferenceOfDays(dateRange.from, dateRange.to)
|
||||
: FilterDropDownLabels.ALL_TIME
|
||||
);
|
||||
const [selectingDate, setSelectingDate] = useState<DateSelected>(DateSelected.FROM);
|
||||
const [isDatePickerOpen, setIsDatePickerOpen] = useState<boolean>(false);
|
||||
const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState<boolean>(false);
|
||||
const [hoveredRange, setHoveredRange] = useState<DateRange | null>(null);
|
||||
|
||||
// when the page loads we get total responses and iterate over the responses and questions, tags and attributes to create the filter options
|
||||
useEffect(() => {
|
||||
const { questionFilterOptions, questionOptions } = generateQuestionAndFilterOptions(
|
||||
survey,
|
||||
environmentTags,
|
||||
attributes
|
||||
);
|
||||
setSelectedOptions({ questionFilterOptions, questionOptions });
|
||||
}, [survey, setSelectedOptions, environmentTags, attributes]);
|
||||
|
||||
const datePickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const extracMetadataKeys = useCallback((obj, parentKey = "") => {
|
||||
let keys: string[] = [];
|
||||
|
||||
for (let key in obj) {
|
||||
if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||
keys = keys.concat(extracMetadataKeys(obj[key], parentKey + key + " - "));
|
||||
} else {
|
||||
keys.push(parentKey + key);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}, []);
|
||||
|
||||
const handleDateHoveredChange = (date: Date) => {
|
||||
if (selectingDate === DateSelected.FROM) {
|
||||
const startOfRange = new Date(date);
|
||||
startOfRange.setHours(0, 0, 0, 0); // Set to the start of the selected day
|
||||
|
||||
// Check if the selected date is after the current 'to' date
|
||||
if (startOfRange > dateRange?.to!) {
|
||||
return;
|
||||
} else {
|
||||
setHoveredRange({ from: startOfRange, to: dateRange.to });
|
||||
}
|
||||
} else {
|
||||
const endOfRange = new Date(date);
|
||||
endOfRange.setHours(23, 59, 59, 999); // Set to the end of the selected day
|
||||
|
||||
// Check if the selected date is before the current 'from' date
|
||||
if (endOfRange < dateRange?.from!) {
|
||||
return;
|
||||
} else {
|
||||
setHoveredRange({ from: dateRange.from, to: endOfRange });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateChange = (date: Date) => {
|
||||
if (selectingDate === DateSelected.FROM) {
|
||||
const startOfRange = new Date(date);
|
||||
startOfRange.setHours(0, 0, 0, 0); // Set to the start of the selected day
|
||||
|
||||
// Check if the selected date is after the current 'to' date
|
||||
if (startOfRange > dateRange?.to!) {
|
||||
const nextDay = new Date(startOfRange);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
nextDay.setHours(23, 59, 59, 999);
|
||||
setDateRange({ from: startOfRange, to: nextDay });
|
||||
} else {
|
||||
setDateRange((prevData) => ({ from: startOfRange, to: prevData.to }));
|
||||
}
|
||||
setSelectingDate(DateSelected.TO);
|
||||
} else {
|
||||
const endOfRange = new Date(date);
|
||||
endOfRange.setHours(23, 59, 59, 999); // Set to the end of the selected day
|
||||
|
||||
// Check if the selected date is before the current 'from' date
|
||||
if (endOfRange < dateRange?.from!) {
|
||||
const previousDay = new Date(endOfRange);
|
||||
previousDay.setDate(previousDay.getDate() - 1);
|
||||
previousDay.setHours(0, 0, 0, 0); // Set to the start of the selected day
|
||||
setDateRange({ from: previousDay, to: endOfRange });
|
||||
} else {
|
||||
setDateRange((prevData) => ({ from: prevData?.from, to: endOfRange }));
|
||||
}
|
||||
setIsDatePickerOpen(false);
|
||||
setSelectingDate(DateSelected.FROM);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDatePickerClose = () => {
|
||||
setIsDatePickerOpen(false);
|
||||
setSelectingDate(DateSelected.FROM);
|
||||
};
|
||||
|
||||
useClickOutside(datePickerRef, () => handleDatePickerClose());
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative mb-12 flex justify-between">
|
||||
<div className="flex justify-stretch gap-x-1.5">
|
||||
<ResponseFilter />
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
setIsFilterDropDownOpen(value);
|
||||
}}>
|
||||
<DropdownMenuTrigger>
|
||||
<div className="flex h-auto min-w-[8rem] items-center justify-between rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
|
||||
<span className="text-sm text-slate-700">
|
||||
{filterRange === FilterDropDownLabels.CUSTOM_RANGE
|
||||
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
|
||||
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
|
||||
}`
|
||||
: filterRange}
|
||||
</span>
|
||||
{isFilterDropDownOpen ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="hover:ring-0"
|
||||
onClick={() => {
|
||||
setFilterRange(FilterDropDownLabels.ALL_TIME);
|
||||
setDateRange({ from: undefined, to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">All time</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="hover:ring-0"
|
||||
onClick={() => {
|
||||
setFilterRange(FilterDropDownLabels.LAST_7_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">Last 7 days</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="hover:ring-0"
|
||||
onClick={() => {
|
||||
setFilterRange(FilterDropDownLabels.LAST_30_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">Last 30 days</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="hover:ring-0"
|
||||
onClick={() => {
|
||||
setIsDatePickerOpen(true);
|
||||
setFilterRange(FilterDropDownLabels.CUSTOM_RANGE);
|
||||
setSelectingDate(DateSelected.FROM);
|
||||
}}>
|
||||
<p className="text-sm text-slate-700 hover:ring-0">Custom range...</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{isDatePickerOpen && (
|
||||
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">
|
||||
<Calendar
|
||||
initialFocus
|
||||
mode="range"
|
||||
defaultMonth={dateRange?.from}
|
||||
selected={hoveredRange ? hoveredRange : dateRange}
|
||||
numberOfMonths={2}
|
||||
onDayClick={(date) => handleDateChange(date)}
|
||||
onDayMouseEnter={handleDateHoveredChange}
|
||||
onDayMouseLeave={() => setHoveredRange(null)}
|
||||
classNames={{
|
||||
day_today: "hover:bg-slate-200 bg-white",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomFilter;
|
||||
@@ -1,21 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
interface SummaryHeaderProps {
|
||||
survey: TSurvey;
|
||||
product: TProduct;
|
||||
}
|
||||
const SummaryHeader = ({ survey, product }: SummaryHeaderProps) => {
|
||||
return (
|
||||
<div className="mb-11 mt-6 flex flex-wrap items-center justify-between">
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-slate-800">{survey.name}</p>
|
||||
<span className="text-base font-extralight text-slate-600">{product.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SummaryHeader;
|
||||
Reference in New Issue
Block a user