Move Summary & responses page over to serverside data-retrieval (#433)

* use new services for server-side data retrieval in survey responses & summary

* fix build errors

* add notes to response schema

* add response notes

* fix type conflicts

* add tag functionality

* run pnpm format

* fix tag state not updating correctly
This commit is contained in:
Matti Nannt
2023-06-25 17:26:17 +02:00
committed by GitHub
parent 2e662f98b9
commit 8486e516b6
31 changed files with 435 additions and 360 deletions
@@ -262,18 +262,17 @@ export default function Header() {
className="text-base font-medium text-slate-400 hover:text-slate-700 dark:hover:text-slate-300">
Blog {/* <p className="bg-brand inline rounded-full px-2 text-xs text-white">1</p> */}
</Link>
{/* <Link
{/* <Link
href="/careers"
className="text-base font-medium text-slate-400 hover:text-slate-700 dark:hover:text-slate-300">
Careers <p className="bg-brand inline rounded-full px-2 text-xs text-white">2</p>
</Link> */}
<Link
<Link
href="/community"
className="text-base font-medium text-slate-400 hover:text-slate-700 dark:hover:text-slate-300">
Community
</Link>
</Popover.Group>
<div className="hidden flex-1 items-center justify-end md:flex">
<ThemeSelector className="relative z-10 mr-5" />
@@ -368,7 +367,7 @@ export default function Header() {
<Link href="#pricing">Pricing</Link>
<Link href="/docs">Docs</Link>
<Link href="/blog">Blog</Link>
{/* <Link href="/careers">Careers</Link> */}
{/* <Link href="/careers">Careers</Link> */}
<Button
variant="secondary"
EndIcon={GitHubIcon}
+1 -1
View File
@@ -27,7 +27,7 @@ export default function CareersPage() {
headingPt2="decisions."
subheading="We are currently not hiring. Contributions are always welcome!"
/>
{/*
{/*
<div className="mx-auto w-3/4">
{Roles.map((role) => (
+1 -1
View File
@@ -41,7 +41,7 @@ const CommunityPage = () => {
Top Contributors
</h2>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
Super thankful to have you guys contribute for Formbricks 🙌
Super thankful to have you guys contribute for Formbricks 🙌
</p>
<ol className="ml-4 mt-10 list-decimal">
{topContributors.map((MVP) => (
@@ -295,7 +295,7 @@ export const authOptions: NextAuthOptions = {
name: "50% Scroll",
description: "A user scrolled 50% of the current page",
type: "automatic",
}
},
],
},
attributeClasses: {
@@ -331,7 +331,7 @@ export const authOptions: NextAuthOptions = {
name: "50% Scroll",
description: "A user scrolled 50% of the current page",
type: "automatic",
}
},
],
},
attributeClasses: {
@@ -38,7 +38,7 @@ export async function createTeam(teamName: string, ownerUserId: string): Promise
name: "50% Scroll",
description: "A user scrolled 50% of the current page",
type: "automatic",
}
},
],
},
attributeClasses: {
@@ -1,17 +1,20 @@
"use client";
import { RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
import { useResponses } from "@/lib/responses/responses";
import Link from "next/link";
export default function ResponsesLimitReachedBanner({ environmentId, surveyId }) {
const { responsesData } = useResponses(environmentId, surveyId);
const reachedLimit = responsesData?.reachedLimit;
const count = responsesData?.count;
interface ResponsesLimitReachedBannerProps {
environmentId: string;
limitReached: boolean;
responsesCount: number;
}
export default function ResponsesLimitReachedBanner({
environmentId,
limitReached,
responsesCount,
}: ResponsesLimitReachedBannerProps) {
return (
<>
{reachedLimit && (
{limitReached && (
<div className="bg-brand-light relative isolate flex items-center gap-x-6 overflow-hidden px-6 py-2.5 sm:px-3.5 sm:before:flex-1">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<p className="text-sm leading-6 text-gray-900">
@@ -19,7 +22,7 @@ export default function ResponsesLimitReachedBanner({ environmentId, surveyId })
<svg viewBox="0 0 2 2" className="mx-2 inline h-0.5 w-0.5 fill-current" aria-hidden="true">
<circle cx={1} cy={1} r={1} />
</svg>
You can only see {RESPONSES_LIMIT_FREE} of the {count} responses you received.
You can only see {RESPONSES_LIMIT_FREE} of the {responsesCount} responses you received.
</p>
<Link
href={`/environments/${environmentId}/settings/billing`}
@@ -1,36 +1,45 @@
"use client";
import { OpenTextSummaryProps } from "@/app/environments/[environmentId]/surveys/[surveyId]/responses/SingleResponse";
import { addResponseNote } from "@/lib/responseNotes/responsesNotes";
import { useResponses } from "@/lib/responses/responses";
import { timeSince } from "@formbricks/lib/time";
import { TResponseNote } from "@formbricks/types/v1/responses";
import { Button } from "@formbricks/ui";
import { PlusIcon } from "@heroicons/react/24/outline";
import { MinusIcon } from "@heroicons/react/24/solid";
import clsx from "clsx";
import { Maximize2Icon } from "lucide-react";
import { useRouter } from "next/navigation";
import { FormEvent, useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
export default function ResponseNote({
data,
interface ResponseNotesProps {
responseId: string;
notes: TResponseNote[];
environmentId: string;
surveyId: string;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function ResponseNotes({
responseId,
notes,
environmentId,
surveyId,
isOpen,
setIsOpen,
}: OpenTextSummaryProps & { isOpen: boolean; setIsOpen: (isOpen: boolean) => void }) {
}: ResponseNotesProps) {
const router = useRouter();
const [noteText, setNoteText] = useState("");
const [isCreatingNote, setIsCreatingNote] = useState(false);
const { mutateResponses } = useResponses(environmentId, surveyId);
const responseNotes = data?.responseNotes;
const divRef = useRef<HTMLDivElement>(null);
const handleNoteSubmission = async (e: FormEvent) => {
e.preventDefault();
setIsCreatingNote(true);
try {
await addResponseNote(environmentId, surveyId, data?.id, noteText);
mutateResponses();
await addResponseNote(environmentId, surveyId, responseId, noteText);
router.refresh();
setIsCreatingNote(false);
setNoteText("");
} catch (e) {
@@ -43,17 +52,17 @@ export default function ResponseNote({
if (divRef.current) {
divRef.current.scrollTop = divRef.current.scrollHeight;
}
}, [responseNotes]);
}, [notes]);
return (
<div
className={clsx(
"absolute w-1/4 rounded-lg border border-slate-200 shadow-sm transition-all",
!isOpen && responseNotes.length && "group/hint cursor-pointer bg-white hover:-right-3",
!isOpen && !responseNotes.length && "cursor-pointer bg-slate-50",
!isOpen && notes.length && "group/hint cursor-pointer bg-white hover:-right-3",
!isOpen && !notes.length && "cursor-pointer bg-slate-50",
isOpen
? "-right-5 top-0 h-5/6 max-h-[600px] w-1/4 bg-white"
: responseNotes.length
: notes.length
? "right-0 top-[8.33%] h-5/6 max-h-[600px] w-1/12"
: "right-[120px] top-[8.333%] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"
)}
@@ -65,9 +74,9 @@ export default function ResponseNote({
<div
className={clsx(
"space-y-2 rounded-t-lg px-2 pb-2 pt-2",
responseNotes.length ? "flex h-12 items-center justify-end bg-amber-50" : "bg-slate-200"
notes.length ? "flex h-12 items-center justify-end bg-amber-50" : "bg-slate-200"
)}>
{!responseNotes.length ? (
{!notes.length ? (
<div className="flex items-center justify-end">
<div className="group flex items-center">
<h3 className="float-left ml-4 pb-1 text-sm text-slate-600">Note</h3>
@@ -79,7 +88,7 @@ export default function ResponseNote({
</div>
)}
</div>
{!responseNotes.length ? (
{!notes.length ? (
<div className="flex flex-1 items-center justify-end pr-3">
<span>
<PlusIcon className=" h-5 w-5 text-slate-400" />
@@ -104,14 +113,14 @@ export default function ResponseNote({
</div>
</div>
<div className="flex-1 overflow-auto px-4 pt-2" ref={divRef}>
{responseNotes.map((note) => (
{notes.map((note) => (
<div className="mb-3" key={note.id}>
<span className="block font-semibold text-slate-700">
{note.user.name}
<time
className="ml-2 text-xs font-normal text-slate-500"
dateTime={timeSince(data.updatedAt)}>
{timeSince(note.updatedAt)}
dateTime={timeSince(note.updatedAt.toISOString())}>
{timeSince(note.updatedAt.toISOString())}
</time>
</span>
<span className="block text-slate-700">{note.text}</span>
@@ -119,11 +128,7 @@ export default function ResponseNote({
))}
</div>
<div className="h-[120px]">
<div
className={clsx(
"absolute bottom-0 w-full px-3 pb-3",
!responseNotes.length && "absolute bottom-0"
)}>
<div className={clsx("absolute bottom-0 w-full px-3 pb-3", !notes.length && "absolute bottom-0")}>
<form onSubmit={handleNoteSubmission}>
<div className="mt-4">
<textarea
@@ -1,66 +1,21 @@
import { useResponses } from "@/lib/responses/responses";
import { removeTagFromResponse, useAddTagToResponse } from "@/lib/tags/mutateTags";
import { useCreateTag } from "@/lib/tags/mutateTags";
import { useTagsForEnvironment } from "@/lib/tags/tags";
import React from "react";
import { useState } from "react";
import { XCircleIcon } from "@heroicons/react/24/solid";
import TagsCombobox from "@/app/environments/[environmentId]/surveys/[surveyId]/responses/TagsCombobox";
import { useResponses } from "@/lib/responses/responses";
import { removeTagFromResponse, useAddTagToResponse, useCreateTag } from "@/lib/tags/mutateTags";
import { useTagsForEnvironment } from "@/lib/tags/tags";
import React, { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { useEffect } from "react";
import { Tag } from "./Tag";
interface ResponseTagsWrapperProps {
tags: {
tagId: string;
tagName: string;
}[];
environmentId: string;
surveyId: string;
productId: string;
responseId: string;
}
export function Tag({
tagId,
tagName,
onDelete,
tags,
setTagsState,
highlight,
}: {
tagId: string;
tagName: string;
onDelete: (tagId: string) => void;
tags: ResponseTagsWrapperProps["tags"];
setTagsState: (tags: ResponseTagsWrapperProps["tags"]) => void;
highlight?: boolean;
}) {
return (
<div
key={tagId}
className={cn(
"relative flex items-center justify-between gap-2 rounded-full border bg-slate-600 px-2 py-1 text-slate-100",
highlight && "border-2 border-green-600"
)}>
<div className="flex items-center gap-2">
<span className="text-sm">{tagName}</span>
</div>
<span
className="cursor-pointer text-sm"
onClick={() => {
setTagsState(tags.filter((tag) => tag.tagId !== tagId));
onDelete(tagId);
}}>
<XCircleIcon fontSize={24} className="h-4 w-4 text-slate-100 hover:text-slate-200" />
</span>
</div>
);
}
const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
tags,
environmentId,
@@ -73,11 +28,8 @@ const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
const [tagIdToHighlight, setTagIdToHighlight] = useState("");
const { createTag } = useCreateTag(environmentId);
const { mutateResponses } = useResponses(environmentId, surveyId);
const { data: environmentTags, mutate: refetchEnvironmentTags } = useTagsForEnvironment(environmentId);
const { addTagToRespone } = useAddTagToResponse(environmentId, surveyId, responseId);
const onDelete = async (tagId: string) => {
@@ -121,7 +73,7 @@ const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
searchValue={searchValue}
setSearchValue={setSearchValue}
tags={environmentTags?.map((tag) => ({ value: tag.id, label: tag.name })) ?? []}
currentTags={tags.map((tag) => ({ value: tag.tagId, label: tag.tagName }))}
currentTags={tagsState.map((tag) => ({ value: tag.tagId, label: tag.tagName }))}
createTag={(tagName) => {
createTag(
{
@@ -1,29 +1,32 @@
"use client";
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useResponses } from "@/lib/responses/responses";
import { generateQuestionsAndAttributes, useSurvey } from "@/lib/surveys/surveys";
import { Button, ErrorComponent } from "@formbricks/ui";
import { useMemo } from "react";
import SingleResponse from "./SingleResponse";
import { convertToCSV } from "@/lib/csvConversion";
import { useCallback } from "react";
import { ArrowDownTrayIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { generateQuestionsAndAttributes } from "@/lib/surveys/surveys";
import { getTodaysDateFormatted } from "@formbricks/lib/time";
import { useProduct } from "@/lib/products/products";
import { TResponse } from "@formbricks/types/v1/responses";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { Button } from "@formbricks/ui";
import { ArrowDownTrayIcon } from "@heroicons/react/24/outline";
import { createId } from "@paralleldrive/cuid2";
import { useCallback, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import SingleResponse from "./SingleResponse";
export default function ResponseTimeline({ environmentId, surveyId }) {
const { responsesData, isLoadingResponses, isErrorResponses } = useResponses(environmentId, surveyId);
const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId);
const { product } = useProduct(environmentId);
const responses = responsesData?.responses;
interface ResponseTimelineProps {
environmentId: string;
surveyId: string;
responses: TResponse[];
survey: TSurvey;
}
export default function ResponseTimeline({
environmentId,
surveyId,
responses,
survey,
}: ResponseTimelineProps) {
const { attributeMap, questionNames } = generateQuestionsAndAttributes(survey, responses);
const [isDownloadCSVLoading, setIsDownloadCSVLoading] = useState(false);
const matchQandA = useMemo(() => {
@@ -37,6 +40,7 @@ export default function ResponseTimeline({ environmentId, surveyId }) {
// Replace question IDs with question headlines in response data
const updatedResponses = responses.map((response) => {
const updatedResponse: Array<{
id: string;
question: string;
answer: string;
type: string;
@@ -48,6 +52,7 @@ export default function ResponseTimeline({ environmentId, surveyId }) {
const answer = response.data[question.id];
if (answer) {
updatedResponse.push({
id: createId(),
question: question.headline,
type: question.type,
scale: question.scale,
@@ -56,12 +61,12 @@ export default function ResponseTimeline({ environmentId, surveyId }) {
});
}
}
return { ...response, responses: updatedResponse, person: response.person };
return { ...response, responses: updatedResponse };
});
const updatedResponsesWithTags = updatedResponses.map((response) => ({
...response,
tags: response.tags?.map((tag) => tag.tag),
tags: response.tags?.map((tag) => tag),
}));
return updatedResponsesWithTags;
@@ -164,14 +169,6 @@ export default function ResponseTimeline({ environmentId, surveyId }) {
URL.revokeObjectURL(downloadUrl);
}, [attributeMap, csvFileName, matchQandA, questionNames]);
if (isLoadingResponses || isLoadingSurvey) {
return <LoadingSpinner />;
}
if (isErrorResponses || isErrorSurvey) {
return <ErrorComponent />;
}
return (
<div className="space-y-4">
{responses.length === 0 ? (
@@ -191,7 +188,6 @@ export default function ResponseTimeline({ environmentId, surveyId }) {
data={updatedResponse}
surveyId={surveyId}
environmentId={environmentId}
productId={product?.id ?? ""}
/>
);
})}
@@ -1,49 +1,26 @@
"use client";
import DeleteDialog from "@/components/shared/DeleteDialog";
import { deleteSubmission, useResponses } from "@/lib/responses/responses";
import { truncate } from "@/lib/utils";
import { timeSince } from "@formbricks/lib/time";
import { PersonAvatar, TooltipContent, TooltipProvider, TooltipTrigger, Tooltip } from "@formbricks/ui";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import { QuestionType } from "@formbricks/types/questions";
import { TResponse } from "@formbricks/types/v1/responses";
import { PersonAvatar, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
import { TrashIcon } from "@heroicons/react/24/outline";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import clsx from "clsx";
import Link from "next/link";
import { useState } from "react";
import toast from "react-hot-toast";
import { RatingResponse } from "../RatingResponse";
import { deleteSubmission, useResponses } from "@/lib/responses/responses";
import clsx from "clsx";
import ResponseNote from "./ResponseNote";
import ResponseTagsWrapper from "@/app/environments/[environmentId]/surveys/[surveyId]/responses/ResponseTagsWrapper";
import { TTag } from "@formbricks/types/v1/tags";
import { QuestionType } from "@formbricks/types/questions";
import ResponseTagsWrapper from "./ResponseTagsWrapper";
export interface OpenTextSummaryProps {
data: {
id: string;
personId: string;
surveyId: string;
person: {
id: string;
createdAt: string;
updatedAt: string;
environmentId: string;
attributes: [];
};
personAttributes: {
[key: string]: string;
};
responseNotes: {
updatedAt: string;
createdAt: string;
id: string;
text: string;
user: {
name: string;
};
}[];
tags: TTag[];
value: string;
updatedAt: string;
finished: boolean;
environmentId: string;
surveyId: string;
data: TResponse & {
responses: {
id: string;
question: string;
@@ -53,20 +30,16 @@ export interface OpenTextSummaryProps {
range?: number;
}[];
};
environmentId: string;
surveyId: string;
productId: string;
}
function findEmail(person) {
const emailAttribute = person.attributes.find((attr) => attr.attributeClass.name === "email");
const emailAttribute = person.attributes.email;
return emailAttribute ? emailAttribute.value : null;
}
export default function SingleResponse({ data, environmentId, surveyId, productId }: OpenTextSummaryProps) {
export default function SingleResponse({ data, environmentId, surveyId }: OpenTextSummaryProps) {
const email = data.person && findEmail(data.person);
const displayIdentifier = email || data.personId;
const responseNotes = data?.responseNotes;
const displayIdentifier = email || (data.person && truncate(data.person.id, 16)) || null;
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const { mutateResponses } = useResponses(environmentId, surveyId);
const [isDeleting, setIsDeleting] = useState(false);
@@ -86,7 +59,7 @@ export default function SingleResponse({ data, environmentId, surveyId, productI
{Object.keys(data.personAttributes).map((key) => {
return (
<p>
{key}: <span className="font-bold">{data.personAttributes[key]}</span>
{key}: <span className="font-bold">{data.personAttributes && data.personAttributes[key]}</span>
</p>
);
})}
@@ -98,18 +71,18 @@ export default function SingleResponse({ data, environmentId, surveyId, productI
<div
className={clsx(
"relative z-10 my-6 rounded-lg border border-slate-200 bg-slate-50 shadow-sm transition-all",
isOpen ? "w-3/4" : responseNotes.length ? "w-[96.5%]" : "w-full group-hover:w-[96.5%]"
isOpen ? "w-3/4" : data.notes.length ? "w-[96.5%]" : "w-full group-hover:w-[96.5%]"
)}>
<div className="space-y-2 px-6 pb-5 pt-6">
<div className="flex items-center justify-between">
{data.personId ? (
{data.person?.id ? (
<Link
className="group flex items-center"
href={`/environments/${environmentId}/people/${data.personId}`}>
href={`/environments/${environmentId}/people/${data.person.id}`}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<PersonAvatar personId={data.personId} />
<PersonAvatar personId={data.person.id} />
</TooltipTrigger>
{tooltipContent}
</Tooltip>
@@ -131,8 +104,8 @@ export default function SingleResponse({ data, environmentId, surveyId, productI
Completed <CheckCircleIcon className="ml-1 h-5 w-5 text-green-400" />
</span>
)}
<time className="text-slate-500" dateTime={timeSince(data.updatedAt)}>
{timeSince(data.updatedAt)}
<time className="text-slate-500" dateTime={timeSince(data.updatedAt.toISOString())}>
{timeSince(data.updatedAt.toISOString())}
</time>
<button
onClick={() => {
@@ -167,7 +140,6 @@ export default function SingleResponse({ data, environmentId, surveyId, productI
<ResponseTagsWrapper
environmentId={environmentId}
surveyId={surveyId}
productId={productId}
responseId={data.id}
tags={data.tags.map((tag) => ({ tagId: tag.id, tagName: tag.name }))}
key={data.tags.map((tag) => tag.id).join("-")}
@@ -182,12 +154,12 @@ export default function SingleResponse({ data, environmentId, surveyId, productI
/>
</div>
<ResponseNote
data={data}
responseId={data.id}
notes={data.notes}
environmentId={environmentId}
surveyId={surveyId}
isOpen={isOpen}
setIsOpen={setIsOpen}
productId={productId}
/>
</div>
);
@@ -0,0 +1,55 @@
import { cn } from "@formbricks/lib/cn";
import { XCircleIcon } from "@heroicons/react/24/solid";
interface Tag {
tagId: string;
tagName: string;
}
interface ResponseTagsWrapperProps {
tags: Tag[];
tagId: string;
tagName: string;
onDelete: (tagId: string) => void;
setTagsState: (tags: Tag[]) => void;
highlight?: boolean;
}
export function Tag({
tagId,
tagName,
onDelete,
tags,
setTagsState,
highlight,
}: {
tagId: string;
tagName: string;
onDelete: (tagId: string) => void;
tags: ResponseTagsWrapperProps["tags"];
setTagsState: (tags: ResponseTagsWrapperProps["tags"]) => void;
highlight?: boolean;
}) {
return (
<div
key={tagId}
className={cn(
"relative flex items-center justify-between gap-2 rounded-full border bg-slate-600 px-2 py-1 text-slate-100",
highlight && "border-2 border-green-600"
)}>
<div className="flex items-center gap-2">
<span className="text-sm">{tagName}</span>
</div>
<span
className="cursor-pointer text-sm"
onClick={() => {
setTagsState(tags.filter((tag) => tag.tagId !== tagId));
onDelete(tagId);
}}>
<XCircleIcon fontSize={24} className="h-4 w-4 text-slate-100 hover:text-slate-200" />
</span>
</div>
);
}
@@ -2,8 +2,16 @@ import ContentWrapper from "@/components/shared/ContentWrapper";
import SurveyResultsTabs from "../SurveyResultsTabs";
import ResponseTimeline from "./ResponseTimeline";
import ResponsesLimitReachedBanner from "../ResponsesLimitReachedBanner";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getAnalysisData } from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/data";
export default function ResponsesPage({ params }) {
export default async function ResponsesPage({ params }) {
const session = await getServerSession(authOptions);
if (!session) {
throw new Error("Unauthorized");
}
const { responses, responsesCount, limitReached, survey } = await getAnalysisData(session, params.surveyId);
return (
<>
<SurveyResultsTabs
@@ -11,9 +19,18 @@ export default function ResponsesPage({ params }) {
environmentId={params.environmentId}
surveyId={params.surveyId}
/>
<ResponsesLimitReachedBanner environmentId={params.environmentId} surveyId={params.surveyId} />
<ResponsesLimitReachedBanner
environmentId={params.environmentId}
limitReached={limitReached}
responsesCount={responsesCount}
/>
<ContentWrapper>
<ResponseTimeline environmentId={params.environmentId} surveyId={params.surveyId} />
<ResponseTimeline
environmentId={params.environmentId}
surveyId={params.surveyId}
responses={responses}
survey={survey}
/>
</ContentWrapper>
</>
);
@@ -2,16 +2,15 @@
import CodeBlock from "@/components/shared/CodeBlock";
import Modal from "@/components/shared/Modal";
import { Survey } from "@formbricks/types/surveys";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { Button } from "@formbricks/ui";
import { CheckIcon } from "@heroicons/react/24/outline";
import { CodeBracketIcon, DocumentDuplicateIcon, EyeIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
import { useRef } from "react";
import { useRef, useState } from "react";
import toast from "react-hot-toast";
interface LinkSurveyModalProps {
survey: Survey;
survey: TSurvey;
open: boolean;
setOpen: (open: boolean) => void;
}
@@ -54,7 +54,7 @@ export default function MultipleChoiceSummary({
}
function findEmail(person) {
const emailAttribute = person.attributes.find((attr) => attr.attributeClass.name === "email");
const emailAttribute = person.attributes.email;
return emailAttribute ? emailAttribute.value : null;
}
@@ -79,12 +79,12 @@ export default function MultipleChoiceSummary({
// count the responses
for (const response of questionSummary.responses) {
// if single choice, only add responses that are in the choices
if (isSingleChoice && response.value in resultsDict) {
resultsDict[response.value].count += 1;
if (isSingleChoice && response.value.toString() in resultsDict) {
resultsDict[response.value.toString()].count += 1;
} else if (isSingleChoice) {
// if single choice and not in choices, add to other
addOtherChoice(response, response.value);
} else {
} else if (Array.isArray(response.value)) {
// if multi choice add all responses
for (const choice of response.value) {
if (choice in resultsDict) {
@@ -12,7 +12,7 @@ interface OpenTextSummaryProps {
}
function findEmail(person) {
const emailAttribute = person.attributes.find((attr) => attr.attributeClass.name === "email");
const emailAttribute = person.attributes.email;
return emailAttribute ? emailAttribute.value : null;
}
@@ -39,17 +39,17 @@ export default function OpenTextSummary({ questionSummary, environmentId }: Open
</div>
{questionSummary.responses.map((response) => {
const email = response.person && findEmail(response.person);
const displayIdentifier = email || truncate(response.personId, 16);
const displayIdentifier = email || (response.person && truncate(response.person.id, 16)) || null;
return (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-slate-800">
<div className="pl-6">
{response.personId ? (
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/people/${response.personId}`}>
<PersonAvatar personId={response.personId} />
href={`/environments/${environmentId}/people/${response.person.id}`}>
<PersonAvatar personId={response.person.id} />
<p className="ph-no-capture ml-2 text-slate-600 group-hover:underline">
{displayIdentifier}
@@ -65,7 +65,7 @@ export default function OpenTextSummary({ questionSummary, environmentId }: Open
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{response.value}
</div>
<div className="px-6 text-slate-500">{timeSince(response.updatedAt)}</div>
<div className="px-6 text-slate-500">{timeSince(response.updatedAt.toISOString())}</div>
</div>
);
})}
@@ -30,7 +30,7 @@ export default function RatingSummary({ questionSummary }: RatingSummaryProps) {
// count the responses
for (const response of questionSummary.responses) {
// if single choice, only add responses that are in the choices
if (response.value in resultsDict) {
if (!Array.isArray(response.value) && response.value in resultsDict) {
resultsDict[response.value].count += 1;
}
}
@@ -1,17 +1,4 @@
"use client";
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useResponses } from "@/lib/responses/responses";
import { useSurvey } from "@/lib/surveys/surveys";
import type { QuestionSummary } from "@formbricks/types/responses";
import { ErrorComponent } from "@formbricks/ui";
import { useMemo } from "react";
import CTASummary from "./CTASummary";
import MultipleChoiceSummary from "./MultipleChoiceSummary";
import NPSSummary from "./NPSSummary";
import OpenTextSummary from "./OpenTextSummary";
import RatingSummary from "./RatingSummary";
import {
QuestionType,
type CTAQuestion,
@@ -19,43 +6,40 @@ import {
type MultipleChoiceSingleQuestion,
type NPSQuestion,
type OpenTextQuestion,
type Question,
type RatingQuestion,
} from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { TResponse } from "@formbricks/types/v1/responses";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/v1/surveys";
import CTASummary from "./CTASummary";
import MultipleChoiceSummary from "./MultipleChoiceSummary";
import NPSSummary from "./NPSSummary";
import OpenTextSummary from "./OpenTextSummary";
import RatingSummary from "./RatingSummary";
export default function SummaryList({ environmentId, surveyId }) {
const { responsesData, isLoadingResponses, isErrorResponses } = useResponses(environmentId, surveyId);
const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId);
interface SummaryListProps {
environmentId: string;
responses: TResponse[];
survey: TSurvey;
}
const responses = responsesData?.responses;
const summaryData: QuestionSummary<Question>[] = useMemo(() => {
if (survey && responses) {
return survey.questions.map((question) => {
const questionResponses = responses
.filter((response) => question.id in response.data)
.map((r) => ({
id: r.id,
value: r.data[question.id],
updatedAt: r.updatedAt,
personId: r.personId,
person: r.person,
}));
return {
question,
responses: questionResponses,
};
});
}
return [];
}, [survey, responses]);
if (isLoadingResponses || isLoadingSurvey) {
return <LoadingSpinner />;
}
if (isErrorResponses || isErrorSurvey) {
return <ErrorComponent />;
export default function SummaryList({ environmentId, responses, survey }: SummaryListProps) {
let summaryData: QuestionSummary<TSurveyQuestion>[] = [];
if (survey && responses) {
summaryData = survey.questions.map((question) => {
const questionResponses = responses
.filter((response) => question.id in response.data)
.map((r) => ({
id: r.id,
value: r.data[question.id],
updatedAt: r.updatedAt,
person: r.person,
}));
return {
question,
responses: questionResponses,
};
});
}
return (
@@ -3,8 +3,9 @@
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import SurveyStatusDropdown from "@/components/shared/SurveyStatusDropdown";
import { useEnvironment } from "@/lib/environments/environments";
import { useResponses } from "@/lib/responses/responses";
import { useSurvey } from "@/lib/surveys/surveys";
import { timeSinceConditionally } from "@formbricks/lib/time";
import { TResponse } from "@formbricks/types/v1/responses";
import { TSurvey } from "@formbricks/types/v1/surveys";
import {
Button,
Confetti,
@@ -20,48 +21,57 @@ import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import LinkSurveyModal from "./LinkSurveyModal";
import { timeSinceConditionally } from "@formbricks/lib/time";
export default function SummaryMetadata({ surveyId, environmentId }) {
const { responsesData, isLoadingResponses, isErrorResponses } = useResponses(environmentId, surveyId);
const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId);
interface SummaryMetadataProps {
surveyId: string;
environmentId: string;
responses: TResponse[];
survey: TSurvey;
}
export default function SummaryMetadata({
surveyId,
environmentId,
responses,
survey,
}: SummaryMetadataProps) {
const { environment, isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
const [confetti, setConfetti] = useState(false);
const [showLinkModal, setShowLinkModal] = useState(false);
const searchParams = useSearchParams();
useEffect(() => {
const newSurveyParam = searchParams?.get("success");
if (newSurveyParam && survey && environment) {
setConfetti(true);
toast.success(
survey.type === "web" && !environment.widgetSetupCompleted
? "Almost there! Install widget to start receiving responses."
: "Congrats! Your survey is live.",
{
icon: survey.type === "web" && !environment.widgetSetupCompleted ? "🤏" : "🎉",
duration: 5000,
position: "bottom-right",
if (environment) {
const newSurveyParam = searchParams?.get("success");
if (newSurveyParam && survey && environment) {
setConfetti(true);
toast.success(
survey.type === "web" && !environment.widgetSetupCompleted
? "Almost there! Install widget to start receiving responses."
: "Congrats! Your survey is live.",
{
icon: survey.type === "web" && !environment.widgetSetupCompleted ? "🤏" : "🎉",
duration: 5000,
position: "bottom-right",
}
);
if (survey.type === "link") {
setShowLinkModal(true);
}
);
if (survey.type === "link") {
setShowLinkModal(true);
}
}
}, [environment, searchParams, survey]);
const responses = responsesData?.responses;
const completionRate = useMemo(() => {
if (!responses) return 0;
return (responses.filter((r) => r.finished).length / responses.length) * 100;
}, [responses]);
if (isLoadingResponses || isLoadingSurvey || isLoadingEnvironment) {
if (isLoadingEnvironment) {
return <LoadingSpinner />;
}
if (isErrorResponses || isErrorSurvey || isErrorEnvironment) {
if (isErrorEnvironment) {
return <ErrorComponent />;
}
@@ -72,7 +82,7 @@ export default function SummaryMetadata({ surveyId, environmentId }) {
<div className="flex flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<p className="text-sm text-slate-600">Survey displays</p>
<p className="text-2xl font-bold text-slate-800">
{survey.numDisplays === 0 ? <span>-</span> : survey.numDisplays}
{survey.analytics.numDisplays === 0 ? <span>-</span> : survey.analytics.numDisplays}
</p>
</div>
<div className="flex flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
@@ -90,10 +100,10 @@ export default function SummaryMetadata({ surveyId, environmentId }) {
<QuestionMarkCircleIcon className="mb-1 ml-2 inline h-4 w-4 text-slate-500" />
</p>
<p className="text-2xl font-bold text-slate-800">
{survey.responseRate === null || survey.responseRate === 0 ? (
{survey.analytics.responseRate === null || survey.analytics.responseRate === 0 ? (
<span>-</span>
) : (
<span>{Math.round(survey.responseRate * 100)} %</span>
<span>{Math.round(survey.analytics.responseRate * 100)} %</span>
)}
</p>
</div>
@@ -130,7 +140,7 @@ export default function SummaryMetadata({ surveyId, environmentId }) {
</div>
<div className="flex flex-col justify-between lg:col-span-1">
<div className="text-right text-xs text-slate-400">
Last updated: {timeSinceConditionally(survey.updatedAt)}
Last updated: {timeSinceConditionally(survey.updatedAt.toISOString())}
</div>
<div className="flex justify-end gap-x-1.5">
{survey.type === "link" && (
@@ -0,0 +1,16 @@
import { IS_FORMBRICKS_CLOUD, RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
import { getSurveyResponses } from "@formbricks/lib/services/response";
import { getSurvey } from "@formbricks/lib/services/survey";
import { Session } from "next-auth";
export const getAnalysisData = async (session: Session, surveyId: string) => {
const survey = await getSurvey(surveyId);
if (!survey) throw new Error(`Survey not found: ${surveyId}`);
const allResponses = await getSurveyResponses(surveyId);
const limitReached =
IS_FORMBRICKS_CLOUD && session?.user.plan === "free" && allResponses.length >= RESPONSES_LIMIT_FREE;
const responses = limitReached ? allResponses.slice(0, RESPONSES_LIMIT_FREE) : allResponses;
const responsesCount = allResponses.length;
return { responses, responsesCount, limitReached, survey };
};
@@ -1,17 +1,35 @@
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getAnalysisData } from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/data";
import ContentWrapper from "@/components/shared/ContentWrapper";
import { getServerSession } from "next-auth";
import ResponsesLimitReachedBanner from "../ResponsesLimitReachedBanner";
import SurveyResultsTabs from "../SurveyResultsTabs";
import SummaryList from "./SummaryList";
import SummaryMetadata from "./SummaryMetadata";
import ResponsesLimitReachedBanner from "../ResponsesLimitReachedBanner";
export default function SummaryPage({ params }) {
export default async function SummaryPage({ params }) {
const session = await getServerSession(authOptions);
if (!session) {
throw new Error("Unauthorized");
}
const { responses, responsesCount, limitReached, survey } = await getAnalysisData(session, params.surveyId);
return (
<>
<SurveyResultsTabs activeId="summary" environmentId={params.environmentId} surveyId={params.surveyId} />
<ResponsesLimitReachedBanner environmentId={params.environmentId} surveyId={params.surveyId} />
<ResponsesLimitReachedBanner
environmentId={params.environmentId}
limitReached={limitReached}
responsesCount={responsesCount}
/>
<ContentWrapper>
<SummaryMetadata surveyId={params.surveyId} environmentId={params.environmentId} />
<SummaryList environmentId={params.environmentId} surveyId={params.surveyId} />
<SummaryMetadata
surveyId={params.surveyId}
environmentId={params.environmentId}
responses={responses}
survey={survey}
/>
<SummaryList environmentId={params.environmentId} survey={survey} responses={responses} />
</ContentWrapper>
</>
);
+1 -1
View File
@@ -16,7 +16,7 @@ export const populateEnvironment = {
name: "50% Scroll",
description: "A user scrolled 50% of the current page",
type: EventType.automatic,
}
},
],
},
attributeClasses: {
+6 -9
View File
@@ -1,5 +1,7 @@
import useSWR from "swr";
import { fetcher } from "@formbricks/lib/fetcher";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { TResponse } from "@formbricks/types/v1/responses";
export const useSurveys = (environmentId: string) => {
const { data, error, mutate, isLoading } = useSWR(`/api/v1/environments/${environmentId}/surveys`, fetcher);
@@ -134,34 +136,29 @@ export const duplicateSurvey = async (
}
};
export const generateQuestionsAndAttributes = (survey, responses) => {
export const generateQuestionsAndAttributes = (survey: TSurvey, responses: TResponse[]) => {
let questionNames: string[] = [];
if (survey?.questions) {
questionNames = survey.questions.map((question) => question.headline);
}
const attributeMap: Record<string, Record<string, string | null>> = {};
const attributeMap: Record<string, Record<string, string | number>> = {};
if (responses) {
responses.forEach((response) => {
const { person } = response;
if (person !== null) {
const { id, attributes } = person;
attributes.forEach((attribute) => {
const { attributeClass, value } = attribute;
const attributeName = attributeClass.name;
Object.keys(attributes).forEach((attributeName) => {
if (!attributeMap.hasOwnProperty(attributeName)) {
attributeMap[attributeName] = {};
}
attributeMap[attributeName][id] = value;
attributeMap[attributeName][id] = attributes[attributeName];
});
}
});
}
return {
questionNames,
attributeMap,
@@ -1,6 +1,6 @@
import { prisma } from "@formbricks/database";
import type { NextApiRequest, NextApiResponse } from "next";
import { EventType } from '@prisma/client';
import { EventType } from "@prisma/client";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
const environmentId = req.query.environmentId?.toString();
@@ -14,6 +14,9 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
if (surveyId === undefined) {
return res.status(400).json({ message: "Missing surveyId" });
}
if (targetEnvironmentId === undefined) {
return res.status(400).json({ message: "Missing targetEnvironmentId" });
}
const hasAccess = await hasEnvironmentAccess(req, res, environmentId);
const hasTargetEnvAccess = await hasEnvironmentAccess(req, res, targetEnvironmentId);
@@ -49,7 +49,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
},
},
},
responseNotes: {
notes: {
include: {
response: true,
user: true,
+2 -2
View File
@@ -95,7 +95,7 @@ model Response {
surveyId String
person Person? @relation(fields: [personId], references: [id], onDelete: Cascade)
personId String?
responseNotes ResponseNote[]
notes ResponseNote[]
/// @zod.custom(imports.ZResponseData)
/// [ResponseData]
data Json @default("{}")
@@ -444,7 +444,7 @@ model User {
identityProviderAccountId String?
memberships Membership[]
accounts Account[]
responseNote ResponseNote[]
responseNotes ResponseNote[]
groupId String?
invitesCreated Invite[] @relation("inviteCreatedBy")
invitesAccepted Invite[] @relation("inviteAcceptedBy")
+90 -57
View File
@@ -3,6 +3,59 @@ import { TResponse, TResponseInput, TResponseUpdateInput } from "@formbricks/typ
import { Prisma } from "@prisma/client";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors";
import { getPerson, TransformPersonOutput, transformPrismaPerson } from "./person";
import { TTag } from "@formbricks/types/v1/tags";
const responseSelection = {
id: true,
createdAt: true,
updatedAt: true,
surveyId: true,
finished: true,
data: true,
personAttributes: true,
person: {
select: {
id: true,
attributes: {
select: {
value: true,
attributeClass: {
select: {
name: true,
},
},
},
},
},
},
notes: {
select: {
id: true,
createdAt: true,
updatedAt: true,
text: true,
user: {
select: {
id: true,
name: true,
},
},
},
},
tags: {
select: {
tag: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
environmentId: true,
},
},
},
},
};
export const createResponse = async (responseInput: TResponseInput): Promise<TResponse> => {
try {
@@ -30,19 +83,13 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
personAttributes: person?.attributes,
}),
},
select: {
id: true,
createdAt: true,
updatedAt: true,
surveyId: true,
finished: true,
data: true,
},
select: responseSelection,
});
const response: TResponse = {
...responsePrisma,
person,
person: transformPrismaPerson(responsePrisma.person),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
return response;
@@ -61,30 +108,7 @@ export const getResponse = async (responseId: string): Promise<TResponse | null>
where: {
id: responseId,
},
select: {
id: true,
createdAt: true,
updatedAt: true,
surveyId: true,
finished: true,
data: true,
personAttributes: true,
person: {
select: {
id: true,
attributes: {
select: {
value: true,
attributeClass: {
select: {
name: true,
},
},
},
},
},
},
},
select: responseSelection,
});
if (!responsePrisma) {
@@ -93,8 +117,8 @@ export const getResponse = async (responseId: string): Promise<TResponse | null>
const response: TResponse = {
...responsePrisma,
personAttributes: responsePrisma.personAttributes as Record<string, string | number>,
person: transformPrismaPerson(responsePrisma.person),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
return response;
@@ -107,6 +131,36 @@ export const getResponse = async (responseId: string): Promise<TResponse | null>
}
};
export const getSurveyResponses = async (surveyId: string): Promise<TResponse[]> => {
try {
const responsesPrisma = await prisma.response.findMany({
where: {
surveyId,
},
select: responseSelection,
orderBy: [
{
createdAt: "desc",
},
],
});
const responses: TResponse[] = responsesPrisma.map((responsePrisma) => ({
...responsePrisma,
person: transformPrismaPerson(responsePrisma.person),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
}));
return responses;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};
export const updateResponse = async (
responseId: string,
responseInput: TResponseUpdateInput
@@ -132,34 +186,13 @@ export const updateResponse = async (
finished: responseInput.finished,
data,
},
select: {
id: true,
createdAt: true,
updatedAt: true,
surveyId: true,
finished: true,
data: true,
person: {
select: {
id: true,
attributes: {
select: {
value: true,
attributeClass: {
select: {
name: true,
},
},
},
},
},
},
},
select: responseSelection,
});
const response: TResponse = {
...responsePrisma,
person: transformPrismaPerson(responsePrisma.person),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
return response;
+1 -1
View File
@@ -76,7 +76,7 @@ export const getSurvey = async (surveyId: string): Promise<TSurvey | null> => {
});
// responseRate, rounded to 2 decimal places
const responseRate = Math.round((numDisplaysResponded / numDisplays) * 100) / 100;
const responseRate = numDisplays ? Math.round((numDisplaysResponded / numDisplays) * 100) / 100 : 0;
const transformedSurvey = {
...surveyPrisma,
+5 -13
View File
@@ -1,4 +1,4 @@
import type { Question } from "./questions";
import { TPerson } from "./v1/people";
export interface Response {
id: string;
@@ -15,20 +15,12 @@ export interface Response {
};
}
export interface QuestionSummary<T extends Question> {
export interface QuestionSummary<T> {
question: T;
responses: {
id: string;
personId: string;
value: string;
updatedAt: string;
person?: {
attributes: {
attributeClass: {
name: string;
};
value: string;
};
};
value: string | number | string[];
updatedAt: Date;
person: TPerson | null;
}[];
}
+21 -1
View File
@@ -1,14 +1,32 @@
import { z } from "zod";
import { ZPersonAttributes } from "./people";
import { ZTag } from "./tags";
export const ZResponseData = z.record(z.union([z.string(), z.number(), z.array(z.string())]));
export type TResponseData = z.infer<typeof ZResponseData>;
export const ZResponsePersonAttributes = ZPersonAttributes.optional();
export const ZResponsePersonAttributes = ZPersonAttributes.nullable();
export type TResponsePersonAttributes = z.infer<typeof ZResponsePersonAttributes>;
export const ZResponseNoteUser = z.object({
id: z.string().cuid2(),
name: z.string().nullable(),
});
export type TResponseNoteUser = z.infer<typeof ZResponseNoteUser>;
const ZResponseNote = z.object({
updatedAt: z.date(),
createdAt: z.date(),
id: z.string(),
text: z.string(),
user: ZResponseNoteUser,
});
export type TResponseNote = z.infer<typeof ZResponseNote>;
const ZResponse = z.object({
id: z.string().cuid2(),
createdAt: z.date(),
@@ -23,6 +41,8 @@ const ZResponse = z.object({
personAttributes: ZResponsePersonAttributes,
finished: z.boolean(),
data: ZResponseData,
notes: z.array(ZResponseNote),
tags: z.array(ZTag),
});
export type TResponse = z.infer<typeof ZResponse>;
+5 -1
View File
@@ -108,6 +108,8 @@ const ZSurveyQuestionBase = z.object({
subheader: z.string().optional(),
required: z.boolean(),
buttonLabel: z.string().optional(),
scale: z.enum(["number", "smiley", "star"]).optional(),
range: z.union([z.literal(5), z.literal(3), z.literal(4), z.literal(7), z.literal(10)]).optional(),
logic: z.array(ZSurveyLogic).optional(),
});
@@ -153,7 +155,7 @@ export const ZSurveyCTAQuestion = ZSurveyQuestionBase.extend({
export const ZSurveyRatingQuestion = ZSurveyQuestionBase.extend({
type: z.literal(QuestionType.Rating),
scale: z.union([z.literal("number"), z.literal("smiley"), z.literal("star")]),
scale: z.enum(["number", "smiley", "star"]),
range: z.union([z.literal(5), z.literal(3), z.literal(4), z.literal(7), z.literal(10)]),
lowerLabel: z.string(),
upperLabel: z.string(),
@@ -170,6 +172,8 @@ export const ZSurveyQuestion = z.union([
ZSurveyRatingQuestion,
]);
export type TSurveyQuestion = z.infer<typeof ZSurveyQuestion>;
export const ZSurveyQuestions = z.array(ZSurveyQuestion);
export type TSurveyQuestions = z.infer<typeof ZSurveyQuestions>;