refactor: Unified single response card and improved UX (#750)

Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang Jariwala <dhruwang@Dhruwangs-MacBook-Pro.local>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Dhruwang Jariwala
2023-10-10 02:52:09 +05:30
committed by GitHub
parent d84e06b909
commit 6a280913c3
100 changed files with 1140 additions and 730 deletions

View File

@@ -11,7 +11,7 @@ import {
SelectValue,
} from "@formbricks/ui";
import { CursorArrowRaysIcon } from "@heroicons/react/24/solid";
import Modal from "../shared/Modal";
import { Modal } from "@formbricks/ui";
interface EventDetailModalProps {
open: boolean;

View File

@@ -1,5 +1,5 @@
import { ResponsiveVideo } from "@formbricks/ui";
import Modal from "../shared/Modal";
import { Modal } from "@formbricks/ui";
interface VideoWalkThroughProps {
open: boolean;

View File

@@ -1,75 +0,0 @@
import { Dialog, Transition } from "@headlessui/react";
import { XMarkIcon } from "@heroicons/react/24/solid";
import { Fragment } from "react";
import clsx from "clsx";
type Modal = {
open: boolean;
setOpen: (v: boolean) => void;
children: React.ReactNode;
title?: string;
noPadding?: boolean;
closeOnOutsideClick?: boolean;
};
const Modal: React.FC<Modal> = ({
open,
setOpen,
children,
title,
noPadding,
closeOnOutsideClick = true,
}) => {
return (
<>
<Transition.Root show={open} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={() => closeOnOutsideClick && setOpen(false)}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-slate-500 bg-opacity-30 backdrop-blur-md transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<Dialog.Panel
className={clsx(
"relative transform rounded-lg bg-slate-100 text-left shadow-xl transition-all dark:bg-slate-800 sm:my-8 sm:w-full sm:max-w-xl ",
`${noPadding ? "" : "px-4 pb-4 pt-5 sm:p-6"}`
)}>
<div className="absolute right-0 top-0 hidden pr-4 pt-4 sm:block">
<button
type="button"
className="rounded-md bg-white text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 focus:ring-offset-2 dark:bg-slate-900"
onClick={() => setOpen(false)}>
<span className="sr-only">Close</span>
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
{title && <h3 className="mb-4 text-xl font-bold text-slate-500">{title}</h3>}
{children}
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
</>
);
};
export default Modal;

View File

@@ -1,6 +1,6 @@
"use client";
import DeleteDialog from "@/components/shared/DeleteDialog";
import { DeleteDialog } from "@formbricks/ui";
import type { NoCodeConfig } from "@formbricks/types/events";
import { Button, Input, Label } from "@formbricks/ui";
import { TrashIcon } from "@heroicons/react/24/outline";

View File

@@ -1,6 +1,6 @@
"use client";
import Modal from "@/components/shared/Modal";
import { Modal } from "@formbricks/ui";
import { Button, Input, Label } from "@formbricks/ui";
import { CursorArrowRaysIcon } from "@heroicons/react/24/solid";
import { useState } from "react";

View File

@@ -1,6 +1,6 @@
"use server";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { createActionClass, deleteActionClass, updateActionClass } from "@formbricks/lib/actionClass/service";
import { canUserAccessActionClass } from "@formbricks/lib/actionClass/auth";

View File

@@ -1,4 +1,4 @@
import Modal from "@/components/shared/Modal";
import { Modal } from "@formbricks/ui";
interface UploadAttributesModalProps {
open: boolean;

View File

@@ -1,6 +1,6 @@
"use server";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { prisma } from "@formbricks/database";
import { SHORT_SURVEY_BASE_URL, SURVEY_BASE_URL } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";

View File

@@ -1,7 +1,7 @@
"use client";
import { createProductAction } from "@/app/(app)/environments/[environmentId]/actions";
import Modal from "@/components/shared/Modal";
import { Modal } from "@formbricks/ui";
import { Button, Input, Label } from "@formbricks/ui";
import { PlusCircleIcon } from "@heroicons/react/24/outline";
import { useRouter } from "next/navigation";

View File

@@ -304,7 +304,8 @@ export default function Navigation({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex cursor-pointer flex-row items-center space-x-5">
{session.user.image ? (
<ProfileAvatar userId={session.user.id} />
{/* {session.user.image ? (
<Image
src={session.user.image}
width="100"
@@ -314,7 +315,7 @@ export default function Navigation({
/>
) : (
<ProfileAvatar userId={session.user.id} />
)}
)} */}
<div>
<p className="ph-no-capture ph-no-capture -mb-0.5 text-sm font-bold text-slate-700">
@@ -328,7 +329,7 @@ export default function Navigation({
<DropdownMenuContent className="w-56">
<DropdownMenuLabel className="cursor-default break-all">
<span className="ph-no-capture font-normal">Signed in as </span>
{session?.user?.name.length > 30 ? (
{session?.user?.name && session?.user?.name.length > 30 ? (
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -1,4 +1,4 @@
import Modal from "@/components/shared/Modal";
import { Modal } from "@formbricks/ui";
import { Button, Input, Label } from "@formbricks/ui";
import { LinkIcon } from "@heroicons/react/24/outline";
import clsx from "clsx";

View File

@@ -10,7 +10,7 @@ import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import Image from "next/image";
import Modal from "@/components/shared/Modal";
import { Modal } from "@formbricks/ui";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { ChevronDownIcon } from "@heroicons/react/24/solid";
import { upsertIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions";

View File

@@ -1,7 +1,7 @@
"use client";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions";
import DeleteDialog from "@/components/shared/DeleteDialog";
import { DeleteDialog } from "@formbricks/ui";
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import { timeSince } from "@formbricks/lib/time";
import { TEnvironment } from "@formbricks/types/v1/environment";

View File

@@ -2,7 +2,7 @@ import SurveyCheckboxGroup from "@/app/(app)/environments/[environmentId]/integr
import TriggerCheckboxGroup from "@/app/(app)/environments/[environmentId]/integrations/webhooks/TriggerCheckboxGroup";
import { triggers } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/HardcodedTriggers";
import { testEndpoint } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/testEndpoint";
import Modal from "@/components/shared/Modal";
import { Modal } from "@formbricks/ui";
import { createWebhookAction } from "./actions";
import { TPipelineTrigger } from "@formbricks/types/v1/pipelines";
import { TSurvey } from "@formbricks/types/v1/surveys";

View File

@@ -1,6 +1,6 @@
"use client";
import DeleteDialog from "@/components/shared/DeleteDialog";
import { DeleteDialog } from "@formbricks/ui";
import { Button, Input, Label } from "@formbricks/ui";
import { TrashIcon } from "@heroicons/react/24/outline";
import clsx from "clsx";

View File

@@ -1,6 +1,6 @@
"use server";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { createWebhook, deleteWebhook, updateWebhook } from "@formbricks/lib/webhook/service";
import { TWebhook, TWebhookInput } from "@formbricks/types/v1/webhooks";
import { getServerSession } from "next-auth";

View File

@@ -2,7 +2,7 @@ import EnvironmentsNavbar from "@/app/(app)/environments/[environmentId]/compone
import ToasterClient from "@/components/ToasterClient";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import FormbricksClient from "../../FormbricksClient";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";

View File

@@ -1,31 +1,41 @@
import ResponseTimeline from "@/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponseTimeline";
import { getSurveys } from "@formbricks/lib/survey/service";
import { authOptions } from "@formbricks/lib/authOptions";
import { getResponsesByPersonId } from "@formbricks/lib/response/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { TTag } from "@formbricks/types/v1/tags";
import { getServerSession } from "next-auth";
export default async function ResponseSection({
environment,
personId,
environmentTags,
}: {
environment: TEnvironment;
personId: string;
environmentTags: TTag[];
}) {
const responses = await getResponsesByPersonId(personId);
const surveyIds = responses?.map((response) => response.surveyId) || [];
const surveys: TSurvey[] = surveyIds.length === 0 ? [] : (await getSurveys(environment.id)) ?? [];
const responsesWithSurvey: TResponseWithSurvey[] =
responses?.reduce((acc: TResponseWithSurvey[], response) => {
const thisSurvey = surveys.find((survey) => survey?.id === response.surveyId);
if (thisSurvey) {
acc.push({
...response,
survey: thisSurvey,
});
}
return acc;
}, []) || [];
const session = await getServerSession(authOptions);
return <ResponseTimeline environment={environment} responses={responsesWithSurvey} />;
if (!session) {
throw new Error("No session found");
}
return (
<>
{responses && (
<ResponseTimeline
profile={session.user}
surveys={surveys}
responses={responses}
environment={environment}
environmentTags={environmentTags}
/>
)}
</>
);
}

View File

@@ -1,23 +1,37 @@
"use client";
import ResponseFeed from "@/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponsesFeed";
import { TResponse } from "@formbricks/types/v1/responses";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";
import { ArrowsUpDownIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
import { useState, useEffect } from "react";
import { TTag } from "@formbricks/types/v1/tags";
import { TProfile } from "@formbricks/types/v1/profile";
export default function ResponseTimeline({
surveys,
profile,
environment,
responses,
environmentTags,
}: {
surveys: TSurvey[];
profile: TProfile;
responses: TResponse[];
environment: TEnvironment;
responses: TResponseWithSurvey[];
environmentTags: TTag[];
}) {
const [responsesAscending, setResponsesAscending] = useState(true);
const [responsesAscending, setResponsesAscending] = useState(false);
const [sortedResponses, setSortedResponses] = useState(responses);
const toggleSortResponses = () => {
setResponsesAscending(!responsesAscending);
};
useEffect(() => {
setSortedResponses(responsesAscending ? [...responses].reverse() : responses);
}, [responsesAscending]);
return (
<div className="md:col-span-2">
<div className="flex items-center justify-between pb-6">
@@ -30,7 +44,13 @@ export default function ResponseTimeline({
</button>
</div>
</div>
<ResponseFeed responses={responses} sortByDate={responsesAscending} environment={environment} />
<ResponseFeed
responses={sortedResponses}
environment={environment}
surveys={surveys}
profile={profile}
environmentTags={environmentTags}
/>
</div>
);
}

View File

@@ -1,102 +1,48 @@
import { formatDistance } from "date-fns";
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";
import Link from "next/link";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TProfile } from "@formbricks/types/v1/profile";
import { TResponse } from "@formbricks/types/v1/responses";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { TTag } from "@formbricks/types/v1/tags";
import SingleResponseCard from "@formbricks/ui/SingleResponseCard";
export default function ResponseFeed({
export default async function ResponseFeed({
responses,
sortByDate,
environment,
surveys,
profile,
environmentTags,
}: {
responses: TResponseWithSurvey[];
sortByDate: boolean;
responses: TResponse[];
environment: TEnvironment;
surveys: TSurvey[];
profile: TProfile;
environmentTags: TTag[];
}) {
return (
<>
{responses.length === 0 ? (
<EmptySpaceFiller type="response" environment={environment} />
) : (
<div>
{responses
.slice()
.sort((a, b) =>
sortByDate
? new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
: new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
)
.map((response: TResponseWithSurvey, responseIdx) => (
<li key={response.id} className="list-none">
<div className="relative pb-8">
{responseIdx !== responses.length - 1 ? (
<span
className="absolute left-4 top-4 -ml-px h-full w-0.5 bg-slate-200"
aria-hidden="true"
/>
) : null}
<div className="relative flex space-x-3">
<div className="w-full overflow-hidden rounded-lg bg-white shadow">
<div className="px-4 py-5 sm:p-6">
<div className="flex w-full justify-between">
<div className="text-sm text-slate-400">
<time
className="text-slate-700"
dateTime={formatDistance(response.createdAt, new Date(), {
addSuffix: true,
})}>
{formatDistance(response.createdAt, new Date(), {
addSuffix: true,
})}
</time>
</div>
<div className="flex items-center justify-center space-x-2 rounded-full bg-slate-50 px-3 py-1 text-sm text-slate-600">
<Link
className="hover:underline"
href={`/environments/${environment.id}/surveys/${response.survey.id}/summary`}>
{response.survey.name}
</Link>
<SurveyStatusIndicator
status={response.survey.status}
environment={environment}
type={response.survey.type}
/>
</div>
</div>
<div className="mt-3 space-y-3">
{response.survey.questions.map((question) => (
<div key={question.id}>
<p className="text-sm text-slate-500">{question.headline}</p>
<p className="ph-no-capture my-1 text-lg font-semibold text-slate-700">
{Array.isArray(response.data[question.id])
? (response.data[question.id] as string[]).join(", ")
: response.data[question.id]}
</p>
</div>
))}
</div>
<div className="flex w-full justify-end">
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<p className="text-sm text-slate-500">{response.singleUseId}</p>
</TooltipTrigger>
<TooltipContent>
<p className="text-sm text-slate-500">Single Use Id</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
</div>
</div>
</li>
))}
</div>
responses.map((response, idx) => {
const survey = surveys.find((survey) => {
return survey.id === response.surveyId;
});
return (
<div key={idx}>
{survey && (
<SingleResponseCard
response={response}
survey={survey}
profile={profile}
pageType="people"
environmentTags={environmentTags}
environment={environment}
/>
)}
</div>
);
})
)}
</>
);

View File

@@ -1,7 +1,7 @@
"use client";
import { deletePersonAction } from "@/app/(app)/environments/[environmentId]/people/[personId]/actions";
import DeleteDialog from "@/components/shared/DeleteDialog";
import { DeleteDialog } from "@formbricks/ui";
import { TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";

View File

@@ -1,5 +1,6 @@
import GoBackButton from "@/components/shared/GoBackButton";
import { DeletePersonButton } from "./DeletePersonButton";
import { getPersonIdentifier } from "@formbricks/lib/people/helpers";
import { getPerson } from "@formbricks/lib/person/service";
interface HeadingSectionProps {
@@ -18,7 +19,7 @@ export default async function HeadingSection({ environmentId, personId }: Headin
<GoBackButton />
<div className="flex items-baseline justify-between border-b border-slate-200 pb-6 pt-4">
<h1 className="ph-no-capture text-4xl font-bold tracking-tight text-slate-900">
<span>{person.attributes.email || person.id}</span>
<span>{getPersonIdentifier(person)}</span>
</h1>
<div className="flex items-center space-x-3">
<DeletePersonButton environmentId={environmentId} personId={personId} />

View File

@@ -5,10 +5,12 @@ import AttributesSection from "@/app/(app)/environments/[environmentId]/people/[
import ResponseSection from "@/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponseSection";
import HeadingSection from "@/app/(app)/environments/[environmentId]/people/[personId]/HeadingSection";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
import { getEnvironment } from "@formbricks/lib/environment/service";
export default async function PersonPage({ params }) {
const environment = await getEnvironment(params.environmentId);
const environmentTags = await getTagsByEnvironmentId(params.environmentId);
if (!environment) {
throw new Error("Environment not found");
}
@@ -20,7 +22,11 @@ export default async function PersonPage({ params }) {
<section className="pb-24 pt-6">
<div className="grid grid-cols-1 gap-x-8 md:grid-cols-4">
<AttributesSection personId={params.personId} />
<ResponseSection environment={environment} personId={params.personId} />
<ResponseSection
environment={environment}
personId={params.personId}
environmentTags={environmentTags}
/>
<ActivitySection environmentId={params.environmentId} personId={params.personId} />
</div>
</section>

View File

@@ -1,6 +1,6 @@
"use client";
import Modal from "@/components/shared/Modal";
import { Modal } from "@formbricks/ui";
import { Button, Input, Label } from "@formbricks/ui";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { useForm } from "react-hook-form";

View File

@@ -1,6 +1,6 @@
"use client";
import DeleteDialog from "@/components/shared/DeleteDialog";
import { DeleteDialog } from "@formbricks/ui";
import { capitalizeFirstLetter } from "@/lib/utils";
import { timeSince } from "@formbricks/lib/time";
import { TApiKey } from "@formbricks/types/v1/apiKeys";

View File

@@ -1,6 +1,6 @@
"use server";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { deleteApiKey, createApiKey } from "@formbricks/lib/apiKey/service";
import { canUserAccessApiKey } from "@formbricks/lib/apiKey/auth";

View File

@@ -3,7 +3,7 @@ export const revalidate = REVALIDATION_INTERVAL;
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { getServerSession } from "next-auth";
import { notFound } from "next/navigation";

View File

@@ -1,6 +1,6 @@
"use client";
import Modal from "@/components/shared/Modal";
import { Modal } from "@formbricks/ui";
import {
Button,
Input,

View File

@@ -1,7 +1,7 @@
"use client";
import { deleteTeamAction } from "@/app/(app)/environments/[environmentId]/settings/members/actions";
import DeleteDialog from "@/components/shared/DeleteDialog";
import { DeleteDialog } from "@formbricks/ui";
import { TTeam } from "@formbricks/types/v1/teams";
import { Button, Input } from "@formbricks/ui";
import { useRouter } from "next/navigation";

View File

@@ -7,7 +7,7 @@ import {
deleteMembershipAction,
resendInviteAction,
} from "@/app/(app)/environments/[environmentId]/settings/members/actions";
import DeleteDialog from "@/components/shared/DeleteDialog";
import { DeleteDialog } from "@formbricks/ui";
import { TInvite } from "@formbricks/types/v1/invites";
import { TMember } from "@formbricks/types/v1/memberships";
import { TTeam } from "@formbricks/types/v1/teams";

View File

@@ -1,6 +1,6 @@
"use client";
import Modal from "@/components/shared/Modal";
import { Modal } from "@formbricks/ui";
import { Button } from "@formbricks/ui";
import { CheckIcon } from "@heroicons/react/24/outline";

View File

@@ -1,6 +1,6 @@
"use server";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { createInviteToken } from "@formbricks/lib/jwt";
import { AuthenticationError, AuthorizationError, ValidationError } from "@formbricks/types/v1/errors";
import {

View File

@@ -1,5 +1,5 @@
import TeamActions from "@/app/(app)/environments/[environmentId]/settings/members/EditMemberships/TeamActions";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { getMembershipsByUserId, getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { Skeleton } from "@formbricks/ui";

View File

@@ -1,4 +1,4 @@
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import SettingsCard from "@/app/(app)/environments/[environmentId]/settings/SettingsCard";
import { prisma } from "@formbricks/database";
import { NotificationSettings } from "@formbricks/types/users";

View File

@@ -3,7 +3,7 @@ import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { TProduct } from "@formbricks/types/v1/product";
import DeleteProductRender from "@/app/(app)/environments/[environmentId]/settings/product/DeleteProductRender";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
type DeleteProductProps = {

View File

@@ -1,7 +1,7 @@
"use client";
import { deleteProductAction } from "@/app/(app)/environments/[environmentId]/settings/product/actions";
import DeleteDialog from "@/components/shared/DeleteDialog";
import { DeleteDialog } from "@formbricks/ui";
import { truncate } from "@/lib/utils";
import { TProduct } from "@formbricks/types/v1/product";
import { Button } from "@formbricks/ui";

View File

@@ -1,6 +1,6 @@
"use client";
import DeleteDialog from "@/components/shared/DeleteDialog";
import { DeleteDialog } from "@formbricks/ui";
import AvatarPlaceholder from "@/images/avatar-placeholder.png";
import { formbricksLogout } from "@/lib/formbricks";
import { Button, Input, ProfileAvatar } from "@formbricks/ui";

View File

@@ -1,14 +1,12 @@
"use client";
import AvatarPlaceholder from "@/images/avatar-placeholder.png";
import { Button, ProfileAvatar } from "@formbricks/ui";
import Image from "next/image";
import { Session } from "next-auth";
export function EditAvatar({ session }: { session: Session | null }) {
return (
<div>
{session?.user?.image ? (
{/* {session?.user?.image ? (
<Image
src={AvatarPlaceholder}
width="100"
@@ -18,7 +16,8 @@ export function EditAvatar({ session }: { session: Session | null }) {
/>
) : (
<ProfileAvatar userId={session!.user.id} />
)}
)} */}
<ProfileAvatar userId={session!.user.id} />
<Button className="mt-4" variant="darkCTA" disabled={true}>
Upload Image

View File

@@ -1,6 +1,6 @@
"use server";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { updateProfile, deleteProfile } from "@formbricks/lib/profile/service";
import { TProfileUpdateInput } from "@formbricks/types/v1/profile";
import { getServerSession } from "next-auth";

View File

@@ -2,7 +2,7 @@ export const revalidate = REVALIDATION_INTERVAL;
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import SettingsCard from "../SettingsCard";
import SettingsTitle from "../SettingsTitle";
import { DeleteAccount } from "./DeleteAccount";

View File

@@ -2,7 +2,7 @@
import { deleteTag, mergeTags, updateTagName } from "@formbricks/lib/tag/service";
import { canUserAccessTag } from "@formbricks/lib/tag/auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { getServerSession } from "next-auth";
import { AuthorizationError } from "@formbricks/types/v1/errors";

View File

@@ -5,7 +5,7 @@ import {
deleteSurveyAction,
duplicateSurveyAction,
} from "@/app/(app)/environments/[environmentId]/actions";
import DeleteDialog from "@/components/shared/DeleteDialog";
import { DeleteDialog } from "@formbricks/ui";
import {
DropdownMenu,
DropdownMenuContent,

View File

@@ -1,7 +1,7 @@
import { UsageAttributesUpdater } from "@/app/(app)/FormbricksClient";
import SurveyDropDownMenu from "@/app/(app)/environments/[environmentId]/surveys/SurveyDropDownMenu";
import SurveyStarter from "@/app/(app)/environments/[environmentId]/surveys/SurveyStarter";
import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
import { SurveyStatusIndicator } from "@formbricks/ui";
import { SURVEY_BASE_URL } from "@formbricks/lib/constants";
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
@@ -81,12 +81,9 @@ export default async function SurveysList({ environmentId }: { environmentId: st
<div className="flex items-center">
{survey.status !== "draft" && (
<>
<SurveyStatusIndicator
status={survey.status}
tooltip
environment={environment}
type={survey.type}
/>
{(survey.type === "link" || environment.widgetSetupCompleted) && (
<SurveyStatusIndicator status={survey.status} />
)}
</>
)}
{survey.status === "draft" && (

View File

@@ -0,0 +1,6 @@
'use server'
import { revalidatePath } from 'next/cache'
export default async function revalidateSurveyIdPath(environmentId:string,surveyId:string) {
revalidatePath(`/environments/${environmentId}/surveys/${surveyId}`)
}

View File

@@ -1,6 +1,7 @@
import { cn } from "@formbricks/lib/cn";
import { PresentationChartLineIcon, InboxStackIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import revalidateSurveyIdPath from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
interface SurveyResultsTabProps {
activeId: string;
@@ -31,6 +32,9 @@ export default function SurveyResultsTab({ activeId, environmentId, surveyId }:
{tabs.map((tab) => (
<Link
key={tab.id}
onClick={() => {
revalidateSurveyIdPath(environmentId, surveyId);
}}
href={tab.href}
className={cn(
tab.id === activeId

View File

@@ -1,5 +1,5 @@
import { Metadata } from "next";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { getServerSession } from "next-auth";
import { getAnalysisData } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data";

View File

@@ -1,11 +1,10 @@
"use server";
import { deleteResponse } from "@formbricks/lib/response/service";
import { updateResponseNote, resolveResponseNote } from "@formbricks/lib/responseNote/service";
import { createTag } from "@formbricks/lib/tag/service";
import { addTagToRespone, deleteTagOnResponse } from "@formbricks/lib/tagOnResponse/service";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { getServerSession } from "next-auth";
import { AuthorizationError } from "@formbricks/types/v1/errors";
import { canUserAccessResponse } from "@formbricks/lib/response/auth";

View File

@@ -72,6 +72,7 @@ const ResponsePage = ({
surveyId={surveyId}
responses={filterResponses}
survey={survey}
profile={profile}
environmentTags={environmentTags}
/>
</ContentWrapper>

View File

@@ -1,74 +1,29 @@
"use client";
import EmptyInAppSurveys from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TProfile } from "@formbricks/types/v1/profile";
import { TResponse } from "@formbricks/types/v1/responses";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { createId } from "@paralleldrive/cuid2";
import { useMemo } from "react";
import SingleResponse from "./SingleResponse";
import EmptyInAppSurveys from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TTag } from "@formbricks/types/v1/tags";
import SingleResponseCard from "@formbricks/ui/SingleResponseCard";
interface ResponseTimelineProps {
environment: TEnvironment;
surveyId: string;
responses: TResponse[];
survey: TSurvey;
profile: TProfile;
environmentTags: TTag[];
}
export default function ResponseTimeline({
environment,
surveyId,
responses,
survey,
profile,
environmentTags,
}: ResponseTimelineProps) {
const matchQandA = useMemo(() => {
if (survey && responses) {
// Create a mapping of question IDs to their headlines
const questionIdToHeadline = {};
survey.questions.forEach((question) => {
questionIdToHeadline[question.id] = question.headline;
});
// 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;
scale?: "number" | "star" | "smiley";
range?: number;
}> = []; // Specify the type of updatedData
// iterate over survey questions and build the updated response
for (const question of survey.questions) {
const answer = response.data[question.id];
if (answer !== null && answer !== undefined) {
updatedResponse.push({
id: createId(),
question: question.headline,
type: question.type,
scale: question.scale,
range: question.range,
answer: answer as string,
});
}
}
return { ...response, responses: updatedResponse };
});
const updatedResponsesWithTags = updatedResponses.map((response) => ({
...response,
tags: response.tags?.map((tag) => tag),
}));
return updatedResponsesWithTags;
}
return [];
}, [survey, responses]);
return (
<div className="space-y-4">
{survey.type === "web" && !environment.widgetSetupCompleted ? (
@@ -81,15 +36,18 @@ export default function ResponseTimeline({
/>
) : (
<div>
{matchQandA.map((updatedResponse) => {
{responses.map((response) => {
return (
<SingleResponse
key={updatedResponse.id}
data={updatedResponse}
surveyId={surveyId}
environmentId={environment.id}
environmentTags={environmentTags}
/>
<div key={response.id}>
<SingleResponseCard
survey={survey}
response={response}
profile={profile}
environmentTags={environmentTags}
pageType="response"
environment={environment}
/>
</div>
);
})}
</div>

View File

@@ -1,221 +0,0 @@
"use client";
import { RatingResponse } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/RatingResponse";
import DeleteDialog from "@/components/shared/DeleteDialog";
import { truncate } from "@/lib/utils";
import { timeSince } from "@formbricks/lib/time";
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 { useRouter } from "next/navigation";
import { ReactNode, useState } from "react";
import toast from "react-hot-toast";
import ResponseNote from "./ResponseNote";
import ResponseTagsWrapper from "./ResponseTagsWrapper";
import { deleteResponseAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/actions";
import { TTag } from "@formbricks/types/v1/tags";
export interface OpenTextSummaryProps {
environmentId: string;
surveyId: string;
data: TResponse & {
responses: {
id: string;
question: string;
answer: string | any[];
type: string;
scale?: "number" | "star" | "smiley";
range?: number;
}[];
};
environmentTags: TTag[];
}
function findEmail(person) {
return person.attributes?.email || null;
}
interface TooltipRendererProps {
shouldRender: boolean;
tooltipContent: ReactNode;
children: ReactNode;
}
function TooltipRenderer(props: TooltipRendererProps) {
const { children, shouldRender, tooltipContent } = props;
if (shouldRender) {
return (
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>{children}</TooltipTrigger>
<TooltipContent>{tooltipContent}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return <>{children}</>;
}
export default function SingleResponse({
data,
environmentId,
surveyId,
environmentTags,
}: OpenTextSummaryProps) {
const router = useRouter();
const email = data.person && findEmail(data.person);
const displayIdentifier = email || (data.person && truncate(data.person.id, 16)) || null;
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const handleDeleteSubmission = async () => {
setIsDeleting(true);
const deleteResponseStatus = await deleteResponseAction(data?.id);
router.refresh();
if (deleteResponseStatus) toast.success("Submission deleted successfully.");
setDeleteDialogOpen(false);
setIsDeleting(false);
};
const renderTooltip = Boolean(
(data.personAttributes && Object.keys(data.personAttributes).length > 0) ||
(data.meta?.userAgent && Object.keys(data.meta.userAgent).length > 0)
);
const tooltipContent = (
<>
{data.personAttributes && Object.keys(data.personAttributes).length > 0 && (
<div>
<p className="py-1 font-bold text-slate-700">Person attributes:</p>
{Object.keys(data.personAttributes).map((key) => (
<p key={key}>
{key}: <span className="font-bold">{data.personAttributes && data.personAttributes[key]}</span>
</p>
))}
</div>
)}
{data.meta?.userAgent && Object.keys(data.meta.userAgent).length > 0 && (
<div className="text-slate-600">
{data.personAttributes && Object.keys(data.personAttributes).length > 0 && (
<hr className="my-2 border-slate-200" />
)}
<p className="py-1 font-bold text-slate-700">Device info:</p>
{data.meta?.userAgent?.browser && <p>Browser: {data.meta.userAgent.browser}</p>}
{data.meta?.userAgent?.os && <p>OS: {data.meta.userAgent.os}</p>}
{data.meta?.userAgent && (
<p>Device: {data.meta.userAgent.device ? data.meta.userAgent.device : "PC / Generic device"}</p>
)}
</div>
)}
</>
);
return (
<div className={clsx("group relative", isOpen && "min-h-[300px]")}>
<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" : 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.person?.id ? (
<Link
className="group flex items-center"
href={`/environments/${environmentId}/people/${data.person.id}`}>
<TooltipRenderer shouldRender={renderTooltip} tooltipContent={tooltipContent}>
<PersonAvatar personId={data.person.id} />
</TooltipRenderer>
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600 hover:underline">
{displayIdentifier}
</h3>
</Link>
) : (
<div className="group flex items-center">
<TooltipRenderer shouldRender={renderTooltip} tooltipContent={tooltipContent}>
<PersonAvatar personId="anonymous" />
</TooltipRenderer>
<h3 className="ml-4 pb-1 font-semibold text-slate-600">Anonymous</h3>
</div>
)}
<div className="flex space-x-4 text-sm">
{data.singleUseId && (
<span className="flex items-center rounded-full bg-slate-100 px-3 text-slate-600">
{data.singleUseId}
</span>
)}
{data.finished && (
<span className="flex items-center rounded-full bg-slate-100 px-3 text-slate-600">
Completed <CheckCircleIcon className="ml-1 h-5 w-5 text-green-400" />
</span>
)}
<time className="text-slate-500" dateTime={timeSince(data.updatedAt.toISOString())}>
{timeSince(data.updatedAt.toISOString())}
</time>
<button
onClick={() => {
setDeleteDialogOpen(true);
}}>
<TrashIcon className="h-4 w-4 text-slate-500 hover:text-red-700" />
</button>
</div>
</div>
</div>
<div className="space-y-6 rounded-b-lg bg-white p-6">
{data.responses.map((response, idx) => (
<div key={`${response.id}-${idx}`}>
<p className="text-sm text-slate-500">{response.question}</p>
{typeof response.answer !== "object" ? (
response.type === QuestionType.Rating ? (
<div className="h-8">
<RatingResponse scale={response.scale} answer={response.answer} range={response.range} />
</div>
) : (
<p className="ph-no-capture my-1 whitespace-pre-wrap font-semibold text-slate-700">
{response.answer}
</p>
)
) : (
<p className="ph-no-capture my-1 font-semibold text-slate-700">
{response.answer.join(", ")}
</p>
)}
</div>
))}
</div>
<ResponseTagsWrapper
environmentId={environmentId}
responseId={data.id}
tags={data.tags.map((tag) => ({ tagId: tag.id, tagName: tag.name }))}
key={data.tags.map((tag) => tag.id).join("-")}
environmentTags={environmentTags}
/>
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
deleteWhat="response"
onDelete={handleDeleteSubmission}
isDeleting={isDeleting}
/>
</div>
<ResponseNote
responseId={data.id}
notes={data.notes}
environmentId={environmentId}
surveyId={surveyId}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>
</div>
);
}

View File

@@ -1,6 +1,6 @@
export const revalidate = REVALIDATION_INTERVAL;
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import ResponsePage from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { getAnalysisData } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data";
import { getServerSession } from "next-auth";

View File

@@ -1,7 +1,7 @@
"use server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { sendEmbedSurveyPreviewEmail } from "@formbricks/lib/emails/emails";
import { AuthenticationError } from "@formbricks/types/v1/errors";

View File

@@ -4,7 +4,7 @@ import { PersonAvatar, ProgressBar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
import Link from "next/link";
import { truncate } from "@/lib/utils";
import { getPersonIdentifier } from "@formbricks/lib/people/helpers";
import {
TSurveyMultipleChoiceMultiQuestion,
TSurveyMultipleChoiceSingleQuestion,
@@ -57,20 +57,15 @@ export default function MultipleChoiceSummary({
};
}
function findEmail(person) {
return person.attributes?.email || null;
}
const addOtherChoice = (response, value) => {
for (const key in resultsDict) {
if (resultsDict[key].id === "other" && value !== "") {
const email = response.person && findEmail(response.person);
const displayIdentifier = email || truncate(response.personId, 16);
const displayIdentifier = getPersonIdentifier(response.person);
resultsDict[key].otherValues?.push({
value,
person: {
id: response.personId,
email: displayIdentifier,
email: typeof displayIdentifier === "string" ? displayIdentifier : undefined,
},
});
resultsDict[key].count += 1;
@@ -196,7 +191,7 @@ export default function MultipleChoiceSummary({
</div>
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
{otherValue.person.id && <PersonAvatar personId={otherValue.person.id} />}
<span>{otherValue.person.email}</span>
<span>{getPersonIdentifier(otherValue.person)}</span>
</div>
</Link>
)}

View File

@@ -1,5 +1,5 @@
import { getPersonIdentifier } from "@formbricks/lib/people/helpers";
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
import { truncate } from "@/lib/utils";
import { timeSince } from "@formbricks/lib/time";
import type { QuestionSummary } from "@formbricks/types/responses";
import { TSurveyOpenTextQuestion } from "@formbricks/types/v1/surveys";
@@ -13,10 +13,6 @@ interface OpenTextSummaryProps {
environmentId: string;
}
function findEmail(person) {
return person.attributes?.email || null;
}
export default function OpenTextSummary({ questionSummary, environmentId }: OpenTextSummaryProps) {
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
@@ -43,8 +39,7 @@ export default function OpenTextSummary({ questionSummary, environmentId }: Open
<div className="px-4 md:px-6">Time</div>
</div>
{questionSummary.responses.map((response) => {
const email = response.person && findEmail(response.person);
const displayIdentifier = email || (response.person && truncate(response.person.id, 16)) || null;
const displayIdentifier = getPersonIdentifier(response.person!);
return (
<div
key={response.id}

View File

@@ -4,7 +4,7 @@ import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
import { QuestionType } from "@formbricks/types/questions";
import { TSurveyRatingQuestion } from "@formbricks/types/v1/surveys";
import { RatingResponse } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/RatingResponse";
import { RatingResponse } from "@formbricks/ui";
import { questionTypes } from "@/lib/questions";
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";

View File

@@ -3,7 +3,7 @@ export const revalidate = REVALIDATION_INTERVAL;
import ResponsesLimitReachedBanner from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponsesLimitReachedBanner";
import { getAnalysisData } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data";
import SummaryPage from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { REVALIDATION_INTERVAL, SURVEY_BASE_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";

View File

@@ -15,7 +15,7 @@ import {
DropdownMenuTrigger,
} from "@formbricks/ui";
import { PencilSquareIcon, EllipsisHorizontalIcon } from "@heroicons/react/24/solid";
import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
import { SurveyStatusIndicator } from "@formbricks/ui";
import toast from "react-hot-toast";
import { useRouter } from "next/navigation";
import SuccessMessage from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
@@ -101,11 +101,9 @@ const SummaryHeader = ({
disabled={isStatusChangeDisabled}
style={isStatusChangeDisabled ? { pointerEvents: "none", opacity: 0.5 } : {}}>
<div className="flex items-center">
<SurveyStatusIndicator
status={survey.status}
environment={environment}
type={survey.type}
/>
{(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"}

View File

@@ -1,7 +1,7 @@
"use client";
import AlertDialog from "@/components/shared/AlertDialog";
import DeleteDialog from "@/components/shared/DeleteDialog";
import { DeleteDialog } from "@formbricks/ui";
import SurveyStatusDropdown from "@/components/shared/SurveyStatusDropdown";
import { QuestionType } from "@formbricks/types/questions";
import type { Survey } from "@formbricks/types/surveys";

View File

@@ -3,7 +3,7 @@
import { TSurvey } from "@formbricks/types/v1/surveys";
import { deleteSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { canUserAccessSurvey } from "@formbricks/lib/survey/auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { getServerSession } from "next-auth";
import { AuthorizationError } from "@formbricks/types/v1/errors";

View File

@@ -1,6 +1,6 @@
"use server";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { getServerSession } from "next-auth";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { createSurvey } from "@formbricks/lib/survey/service";

View File

@@ -1,7 +1,7 @@
"use server";
import { createSurvey } from "@formbricks/lib/survey/service";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { getServerSession } from "next-auth";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { AuthorizationError } from "@formbricks/types/v1/errors";

View File

@@ -1,6 +1,6 @@
import FormbricksClient from "@/app/(app)/FormbricksClient";
import { PHProvider, PostHogPageview } from "@/app/PostHogClient";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { Suspense } from "react";

View File

@@ -1,6 +1,6 @@
"use server";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { updateProduct } from "@formbricks/lib/product/service";
import { updateProfile } from "@formbricks/lib/profile/service";
import { TProductUpdateInput } from "@formbricks/types/v1/product";

View File

@@ -1,6 +1,6 @@
export const revalidate = REVALIDATION_INTERVAL;
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { getFirstEnvironmentByUserId } from "@formbricks/lib/environment/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";

View File

@@ -1,6 +1,6 @@
import { sendInviteAcceptedEmail } from "@/lib/email";
import { verifyInviteToken } from "@formbricks/lib/jwt";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database";
import {

View File

@@ -1,4 +1,4 @@
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { hasTeamAccess } from "@/lib/api/apiHelper";
import { getEnvironments } from "@formbricks/lib/environment/service";
import { getProduct } from "@formbricks/lib/product/service";

View File

@@ -1,4 +1,4 @@
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { hasTeamAccess } from "@/lib/api/apiHelper";
import { getEnvironments } from "@formbricks/lib/environment/service";
import { getProducts } from "@formbricks/lib/product/service";

View File

@@ -6,7 +6,7 @@ import {
} from "@formbricks/lib/constants";
import { google } from "googleapis";
import { NextRequest, NextResponse } from "next/server";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { getServerSession } from "next-auth";
const scopes = [

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { AsyncParser } from "@json2csv/node";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { responses } from "@/lib/api/response";
export async function POST(request: NextRequest) {

View File

@@ -1,4 +1,4 @@
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { responses } from "@/lib/api/response";
import { getServerSession } from "next-auth";
import { NextRequest, NextResponse } from "next/server";

View File

@@ -1,5 +1,5 @@
import ClientLogout from "@/app/ClientLogout";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { authOptions } from "@formbricks/lib/authOptions";
import { getFirstEnvironmentByUserId } from "@formbricks/lib/environment/service";
import type { Session } from "next-auth";
import { getServerSession } from "next-auth";

View File

@@ -1,6 +1,6 @@
"use client";
import Modal from "@/components/shared/Modal";
import { Modal } from "@formbricks/ui";
import { Button } from "@formbricks/ui";
interface AlertDialogProps {

View File

@@ -1,6 +1,6 @@
"use client";
import Modal from "@/components/shared/Modal";
import { Modal } from "@formbricks/ui";
import { Button } from "@formbricks/ui";
interface CustomDialogProps {

View File

@@ -1,4 +1,4 @@
import Modal from "@/components/shared/Modal";
import { Modal } from "@formbricks/ui";
import { useEffect, useState } from "react";
interface ModalWithTabsProps {

View File

@@ -1,7 +1,7 @@
"use client";
import { updateSurveyAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions";
import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
import { SurveyStatusIndicator } from "@formbricks/ui";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TSurvey } from "@formbricks/types/v1/surveys";
import {
@@ -35,7 +35,9 @@ export default function SurveyStatusDropdown({
<>
{survey.status === "draft" ? (
<div className="flex items-center">
<SurveyStatusIndicator status={survey.status} environment={environment} type={survey.type} />
{(survey.type === "link" || environment.widgetSetupCompleted) && (
<SurveyStatusIndicator status={survey.status} />
)}
{survey.status === "draft" && <p className="text-sm italic text-slate-600">Draft</p>}
</div>
) : (
@@ -69,11 +71,9 @@ export default function SurveyStatusDropdown({
<SelectTrigger className="w-[170px] bg-white py-6 md:w-[200px]">
<SelectValue>
<div className="flex items-center">
<SurveyStatusIndicator
status={survey.status}
environment={environment}
type={survey.type}
/>
{(survey.type === "link" || environment.widgetSetupCompleted) && (
<SurveyStatusIndicator status={survey.status} />
)}
<span className="ml-2 text-sm text-slate-700">
{survey.status === "inProgress" && "In-progress"}
{survey.status === "paused" && "Paused"}

View File

@@ -1,5 +1,5 @@
import { createTeamAction } from "@/app/(app)/environments/[environmentId]/actions";
import Modal from "@/components/shared/Modal";
import { Modal } from "@formbricks/ui";
import { Button, Input, Label } from "@formbricks/ui";
import { PlusCircleIcon } from "@heroicons/react/24/outline";
import { useRouter } from "next/navigation";

View File

@@ -1,5 +1,5 @@
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { prisma } from "@formbricks/database";
import { authOptions } from "@formbricks/lib/authOptions";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { createHash } from "crypto";
import { NextApiRequest, NextApiResponse } from "next";
@@ -31,36 +31,6 @@ export const hasEnvironmentAccess = async (
return true;
};
export const getPlan = async (req, res) => {
if (req.headers["x-api-key"]) {
const apiKey = req.headers["x-api-key"].toString();
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey: hashApiKey(apiKey),
},
select: {
environment: {
select: {
product: {
select: {
team: {
select: {
plan: true,
},
},
},
},
},
},
},
});
return apiKeyData?.environment.product.team.plan || "free";
} else {
const user = await getSessionUser(req, res);
return user && user.teams?.length > 0 ? user.teams[0].plan : "free";
}
};
export const hasApiEnvironmentAccess = async (apiKey, environmentId) => {
// write function to check if the API Key has access to the environment
const apiKeyData = await prisma.apiKey.findUnique({

View File

@@ -1,7 +1,5 @@
import { getPlan, hasEnvironmentAccess } from "@/lib/api/apiHelper";
import { hasEnvironmentAccess } from "@/lib/api/apiHelper";
import { prisma } from "@formbricks/database";
import { RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import type { NextApiRequest, NextApiResponse } from "next";
@@ -73,16 +71,6 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
},
});
if (IS_FORMBRICKS_CLOUD) {
const plan = await getPlan(req, res);
if (plan === "free" && responses.length > RESPONSES_LIMIT_FREE) {
return res.json({
count: responses.length,
responses: responses.slice(responses.length - RESPONSES_LIMIT_FREE, responses.length), // get last 30 from array
reachedLimit: true,
});
}
}
return res.json({ count: responses.length, responses, reachedLimit: false });
}

366
packages/lib/authOptions.ts Normal file
View File

@@ -0,0 +1,366 @@
import { env } from "@/env.mjs";
import { verifyPassword } from "@/lib/auth";
import { prisma } from "@formbricks/database";
import { EMAIL_VERIFICATION_DISABLED, INTERNAL_SECRET, WEBAPP_URL } from "./constants";
import { verifyToken } from "./jwt";
import { getProfileByEmail } from "./profile/service";
import type { IdentityProvider } from "@prisma/client";
import type { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import GitHubProvider from "next-auth/providers/github";
import GoogleProvider from "next-auth/providers/google";
export const authOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
id: "credentials",
// The name to display on the sign in form (e.g. "Sign in with...")
name: "Credentials",
// The credentials is used to generate a suitable form on the sign in page.
// You can specify whatever fields you are expecting to be submitted.
// e.g. domain, username, password, 2FA token, etc.
// You can pass any HTML attribute to the <input> tag through the object.
credentials: {
email: {
label: "Email Address",
type: "email",
placeholder: "Your email address",
},
password: {
label: "Password",
type: "password",
placeholder: "Your password",
},
},
async authorize(credentials, _req) {
let user;
try {
user = await prisma.user.findUnique({
where: {
email: credentials?.email,
},
});
} catch (e) {
console.error(e);
throw Error("Internal server error. Please try again later");
}
if (!user || !credentials) {
throw new Error("No user matches the provided credentials");
}
if (!user.password) {
throw new Error("No user matches the provided credentials");
}
const isValid = await verifyPassword(credentials.password, user.password);
if (!isValid) {
throw new Error("No user matches the provided credentials");
}
return {
id: user.id,
email: user.email,
firstname: user.firstname,
lastname: user.firstname,
emailVerified: user.emailVerified,
};
},
}),
CredentialsProvider({
id: "token",
// The name to display on the sign in form (e.g. "Sign in with...")
name: "Token",
// The credentials is used to generate a suitable form on the sign in page.
// You can specify whatever fields you are expecting to be submitted.
// e.g. domain, username, password, 2FA token, etc.
// You can pass any HTML attribute to the <input> tag through the object.
credentials: {
token: {
label: "Verification Token",
type: "string",
},
},
async authorize(credentials, _req) {
let user;
try {
if (!credentials?.token) {
throw new Error("Token not found");
}
const { id } = await verifyToken(credentials?.token);
user = await prisma.user.findUnique({
where: {
id: id,
},
});
} catch (e) {
console.error(e);
throw new Error("Either a user does not match the provided token or the token is invalid");
}
if (!user) {
throw new Error("Either a user does not match the provided token or the token is invalid");
}
if (user.emailVerified) {
throw new Error("Email already verified");
}
user = await prisma.user.update({
where: {
id: user.id,
},
data: { emailVerified: new Date().toISOString() },
});
return {
id: user.id,
email: user.email,
firstname: user.firstname,
lastname: user.firstname,
emailVerified: user.emailVerified,
};
},
}),
GitHubProvider({
clientId: env.GITHUB_ID || "",
clientSecret: env.GITHUB_SECRET || "",
}),
GoogleProvider({
clientId: env.GOOGLE_CLIENT_ID || "",
clientSecret: env.GOOGLE_CLIENT_SECRET || "",
allowDangerousEmailAccountLinking: true,
}),
],
callbacks: {
async jwt({ token }) {
const existingUser = await getProfileByEmail(token?.email!);
if (!existingUser) {
return token;
}
const additionalAttributs = {
id: existingUser.id,
createdAt: existingUser.createdAt,
onboardingCompleted: existingUser.onboardingCompleted,
name: existingUser.name,
};
return {
...token,
...additionalAttributs,
};
},
async session({ session, token }) {
// @ts-ignore
session.user.id = token?.id;
// @ts-ignore
session.user.createdAt = token?.createdAt ? new Date(token?.createdAt).toISOString() : undefined;
// @ts-ignore
session.user.onboardingCompleted = token?.onboardingCompleted;
// @ts-ignore
session.user.name = token.name || "";
return session;
},
async signIn({ user, account }: any) {
if (account.provider === "credentials" || account.provider === "token") {
if (!user.emailVerified && !EMAIL_VERIFICATION_DISABLED) {
return `/auth/verification-requested?email=${encodeURIComponent(user.email)}`;
}
return true;
}
if (!user.email || !user.name || account.type !== "oauth") {
return false;
}
if (account.provider) {
const provider = account.provider.toLowerCase() as IdentityProvider;
// check if accounts for this provider / account Id already exists
const existingUserWithAccount = await prisma.user.findFirst({
include: {
accounts: {
where: {
provider: account.provider,
},
},
},
where: {
identityProvider: provider,
identityProviderAccountId: account.providerAccountId,
},
});
if (existingUserWithAccount) {
// User with this provider found
// check if email still the same
if (existingUserWithAccount.email === user.email) {
return true;
}
// user seemed to change his email within the provider
// check if user with this email already exist
// if not found just update user with new email address
// if found throw an error (TODO find better solution)
const otherUserWithEmail = await prisma.user.findFirst({
where: { email: user.email },
});
if (!otherUserWithEmail) {
await prisma.user.update({
where: { id: existingUserWithAccount.id },
data: { email: user.email },
});
return true;
}
return "/auth/login?error=Looks%20like%20you%20updated%20your%20email%20somewhere%20else.%0AA%20user%20with%20this%20new%20email%20exists%20already.";
}
// There is no existing account for this identity provider / account id
// check if user account with this email already exists
// if user already exists throw error and request password login
const existingUserWithEmail = await prisma.user.findFirst({
where: { email: user.email },
});
if (existingUserWithEmail) {
return "/auth/login?error=A%20user%20with%20this%20email%20exists%20already.";
}
const createdUser = await prisma.user.create({
data: {
name: user.name,
email: user.email,
emailVerified: new Date(Date.now()),
onboardingCompleted: false,
identityProvider: provider,
identityProviderAccountId: user.id as string,
accounts: {
create: [{ ...account }],
},
memberships: {
create: [
{
accepted: true,
role: "owner",
team: {
create: {
name: `${user.name}'s Team`,
products: {
create: [
{
name: "My Product",
environments: {
create: [
{
type: "production",
eventClasses: {
create: [
{
name: "New Session",
description: "Gets fired when a new session is created",
type: "automatic",
},
{
name: "Exit Intent (Desktop)",
description: "A user on Desktop leaves the website with the cursor.",
type: "automatic",
},
{
name: "50% Scroll",
description: "A user scrolled 50% of the current page",
type: "automatic",
},
],
},
attributeClasses: {
create: [
{
name: "userId",
description: "The internal ID of the person",
type: "automatic",
},
{
name: "email",
description: "The email of the person",
type: "automatic",
},
],
},
},
{
type: "development",
eventClasses: {
create: [
{
name: "New Session",
description: "Gets fired when a new session is created",
type: "automatic",
},
{
name: "Exit Intent (Desktop)",
description: "A user on Desktop leaves the website with the cursor.",
type: "automatic",
},
{
name: "50% Scroll",
description: "A user scrolled 50% of the current page",
type: "automatic",
},
],
},
attributeClasses: {
create: [
{
name: "userId",
description: "The internal ID of the person",
type: "automatic",
},
{
name: "email",
description: "The email of the person",
type: "automatic",
},
],
},
},
],
},
},
],
},
},
},
},
],
},
},
include: {
memberships: true,
},
});
const teamId = createdUser.memberships?.[0]?.teamId;
if (teamId) {
fetch(`${WEBAPP_URL}/api/v1/teams/${teamId}/add_demo_product`, {
method: "POST",
headers: {
"x-api-key": INTERNAL_SECRET,
},
});
}
return true;
}
return true;
},
},
pages: {
signIn: "/auth/login",
signOut: "/auth/logout",
error: "/auth/login", // Error code passed in query string as ?error=
},
};

View File

@@ -90,9 +90,9 @@ export const sendPasswordResetNotifyEmail = async (user: TEmailUser) => {
export const sendInviteMemberEmail = async (
inviteId: string,
inviterName: string,
inviteeName: string,
email: string
email: string,
inviterName: string | null,
inviteeName: string | null
) => {
const token = createInviteToken(inviteId, email, {
expiresIn: "7d",

View File

@@ -118,8 +118,8 @@ export const inviteUser = async ({
teamId,
}: {
teamId: string;
invitee: { name: string; email: string; role: TMembershipRole };
currentUser: { id: string; name: string };
invitee: { name: string | null; email: string; role: TMembershipRole };
currentUser: { id: string; name: string | null };
}) => {
const { name, email, role } = invitee;
const { id: currentUserId, name: currentUserName } = currentUser;
@@ -157,7 +157,7 @@ export const inviteUser = async ({
},
});
await sendInviteMemberEmail(invite.id, currentUserName, name, email);
await sendInviteMemberEmail(invite.id, email, currentUserName, name);
return invite;
};

View File

@@ -0,0 +1,5 @@
import { TPerson } from "@formbricks/types/v1/people";
export const getPersonIdentifier = (person: TPerson): string | number | null => {
return person?.attributes?.userId || person?.attributes?.email || person?.id || null;
};

View File

@@ -21,6 +21,30 @@ const select = {
},
};
export const createResponseNote = async (
responseId: string,
userId: string,
text: string
): Promise<TResponseNote> => {
try {
const responseNote = await prisma.responseNote.create({
data: {
responseId: responseId,
userId: userId,
text: text,
},
select,
});
return responseNote;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};
export const updateResponseNote = async (responseNoteId: string, text: string): Promise<TResponseNote> => {
try {
const updatedResponseNote = await prisma.responseNote.update({

View File

@@ -5,6 +5,7 @@
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["../../apps/web/*"],
"@prisma/client/*": ["@formbricks/database/client/*"]
}
}

View File

@@ -6,7 +6,8 @@ module.exports = {
"./components/**/*.{js,ts,jsx,tsx}",
"./lib/**/*.{js,ts,jsx,tsx}",
// include packages if not transpiling
"../../packages/ui/components/**/*.{js,ts,jsx,tsx}",
"../../packages/ui/**/*.{js,ts,jsx,tsx}",
"!../../packages/ui/node_modules/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {

View File

@@ -1,22 +1,11 @@
import NextAuth from "next-auth";
import { TProfile } from "./v1/profile";
declare module "next-auth" {
/**
* Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
*/
interface Session {
user: {
id: string;
createdAt: string;
teams: {
id: string;
plan: string;
role: string;
}[];
email: string;
name: string;
onboardingCompleted: boolean;
image?: StaticImageData;
};
user: TProfile;
}
}

View File

@@ -1,7 +1,7 @@
"use client";
import Modal from "@/components/shared/Modal";
import { Button } from "@formbricks/ui";
import { Modal } from "../Modal";
import Button from "../components/Button";
interface DeleteDialogProps {
open: boolean;
@@ -16,7 +16,7 @@ interface DeleteDialogProps {
disabled?: boolean;
}
export default function DeleteDialog({
export function DeleteDialog({
open,
setOpen,
deleteWhat,

View File

@@ -16,7 +16,7 @@ type Modal = {
closeOnOutsideClick?: boolean;
};
const Modal: React.FC<Modal> = ({
export const Modal: React.FC<Modal> = ({
open,
setOpen,
children,
@@ -81,5 +81,3 @@ const Modal: React.FC<Modal> = ({
</>
);
};
export default Modal;

View File

@@ -0,0 +1,41 @@
"use server";
import { getServerSession } from "next-auth";
import { AuthorizationError } from "@formbricks/types/v1/errors";
import { authOptions } from "@formbricks/lib/authOptions";
import { deleteResponse } from "@formbricks/lib/response/service";
import { canUserAccessResponse } from "@formbricks/lib/response/auth";
import {
updateResponseNote,
resolveResponseNote,
createResponseNote,
} from "@formbricks/lib/responseNote/service";
export const deleteResponseAction = async (responseId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessResponse(session.user!.id, responseId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await deleteResponse(responseId);
};
export const updateResponseNoteAction = async (responseNoteId: string, text: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
await updateResponseNote(responseNoteId, text);
};
export const resolveResponseNoteAction = async (responseNoteId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
await resolveResponseNote(responseNoteId);
};
export const createResponseNoteAction = async (responseId: string, userId: string, text: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const authotized = await canUserAccessResponse(session.user!.id, responseId);
if (!authotized) throw new AuthorizationError("Not authorized");
return await createResponseNote(responseId, userId, text);
};

View File

@@ -0,0 +1,78 @@
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../..";
import { ChevronDoubleDownIcon, XCircleIcon } from "@heroicons/react/20/solid";
import { TSurveyQuestion } from "@formbricks/types/v1/surveys";
interface QuestionSkipProps {
skippedQuestions: string[] | undefined;
status: string;
questions: TSurveyQuestion[];
}
export default function QuestionSkip({ skippedQuestions, status, questions }: QuestionSkipProps) {
return (
<>
{skippedQuestions && (
<div className="flex w-full p-2 text-sm text-slate-400">
{status === "skipped" && (
<div className="flex">
<div
className="flex w-0.5 items-center justify-center"
style={{
background:
"repeating-linear-gradient(to bottom, rgb(148 163 184), rgb(148 163 184) 8px, transparent 5px, transparent 15px)", // adjust the values to fit your design
}}>
{skippedQuestions.length > 1 && (
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger asChild>
<ChevronDoubleDownIcon className="w-[1.25rem] min-w-[1.25rem] rounded-full bg-slate-400 p-0.5 text-white" />
</TooltipTrigger>
<TooltipContent>
<p>Respondent skipped these questions.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<div className="ml-6 flex flex-col">
{skippedQuestions &&
skippedQuestions.map((questionId) => {
return (
<p className="my-2" key={questionId}>
{questions.find((question) => question.id === questionId)!.headline}
</p>
);
})}
</div>
</div>
)}
{status === "aborted" && (
<div className="flex">
<div
className="flex w-0.5 flex-grow items-start justify-center"
style={{
background:
"repeating-linear-gradient(to bottom, rgb(148 163 184), rgb(148 163 184) 2px, transparent 2px, transparent 10px)", // adjust the 2px to change dot size and 10px to change space between dots
}}>
<div className="flex">
<XCircleIcon className="min-h-[1.5rem] min-w-[1.5rem] rounded-full bg-white text-slate-400" />
</div>
</div>
<div className="mb-2 ml-4 flex flex-col">
<p className="mb-2 w-fit rounded-lg bg-slate-100 px-2 text-slate-700">Survey Closed</p>
{skippedQuestions &&
skippedQuestions.map((questionId) => {
return (
<p className="my-2" key={questionId}>
{questions.find((question) => question.id === questionId)!.headline}
</p>
);
})}
</div>
</div>
)}
</div>
)}
</>
);
}

View File

@@ -9,14 +9,14 @@ import {
SmilingFaceWithSmilingEyes,
TiredFace,
WearyFace,
} from "@/components/Smileys";
} from "./Smileys";
import { StarIcon } from "@heroicons/react/24/solid";
interface RatingResponseProps {
scale?: "number" | "star" | "smiley";
range?: number;
answer: string;
answer: string | number | string[];
}
export const RatingResponse: React.FC<RatingResponseProps> = ({ scale, range, answer }) => {
@@ -27,7 +27,7 @@ export const RatingResponse: React.FC<RatingResponseProps> = ({ scale, range, an
// show number of stars according to answer value
const stars: any = [];
for (let i = 0; i < range; i++) {
if (i < parseInt(answer)) {
if (i < parseInt(answer.toString())) {
stars.push(<StarIcon key={i} className="h-7 text-yellow-400" />);
} else {
stars.push(<StarIcon key={i} className="h-7 text-gray-300" />);

View File

@@ -1,41 +1,28 @@
"use client";
import {
resolveResponseNoteAction,
updateResponseNoteAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/actions";
import { useProfile } from "@/lib/profile";
import { addResponseNote } from "@/lib/responseNotes/responsesNotes";
import { cn } from "@formbricks/lib/cn";
import { timeSince } from "@formbricks/lib/time";
import { TProfile } from "@formbricks/types/v1/profile";
import { TResponseNote } from "@formbricks/types/v1/responses";
import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
import { CheckIcon, PencilIcon, PlusIcon } from "@heroicons/react/24/solid";
import clsx from "clsx";
import { Maximize2Icon, Minimize2Icon } from "lucide-react";
import { useRouter } from "next/navigation";
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../..";
import { resolveResponseNoteAction, updateResponseNoteAction, createResponseNoteAction } from "../actions";
interface ResponseNotesProps {
profile: TProfile;
responseId: string;
notes: TResponseNote[];
environmentId: string;
surveyId: string;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function ResponseNotes({
responseId,
notes,
environmentId,
surveyId,
isOpen,
setIsOpen,
}: ResponseNotesProps) {
export default function ResponseNotes({ profile, responseId, notes, isOpen, setIsOpen }: ResponseNotesProps) {
const router = useRouter();
const { profile } = useProfile();
const [noteText, setNoteText] = useState("");
const [isCreatingNote, setIsCreatingNote] = useState(false);
const [isUpdatingNote, setIsUpdatingNote] = useState(false);
@@ -47,7 +34,7 @@ export default function ResponseNotes({
e.preventDefault();
setIsCreatingNote(true);
try {
await addResponseNote(environmentId, surveyId, responseId, noteText);
await createResponseNoteAction(responseId, profile.id, noteText);
router.refresh();
setIsCreatingNote(false);
setNoteText("");

View File

@@ -3,10 +3,10 @@
import TagsCombobox from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/TagsCombobox";
import React, { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { Tag } from "./Tag";
import { Tag } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/Tag";
import { ExclamationCircleIcon, Cog6ToothIcon } from "@heroicons/react/24/solid";
import { useRouter } from "next/navigation";
import { Button } from "@formbricks/ui";
import { Button } from "../..";
import { TTag } from "@formbricks/types/v1/tags";
import {
createTagToResponeAction,

View File

@@ -0,0 +1,344 @@
"use client";
import { RatingResponse } from "./components/RatingResponse";
import ResponseNotes from "./components/ResponseNote";
import ResponseTagsWrapper from "./components/ResponseTagsWrapper";
import { deleteResponseAction } from "./actions";
import { DeleteDialog } from "../DeleteDialog";
import QuestionSkip from "./components/QuestionSkip";
import { SurveyStatusIndicator } from "../SurveyStatusIndicator";
import { timeSince } from "@formbricks/lib/time";
import { QuestionType } from "@formbricks/types/questions";
import { TResponse } from "@formbricks/types/v1/responses";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { PersonAvatar, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "..";
import { TrashIcon } from "@heroicons/react/24/outline";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import clsx from "clsx";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ReactNode, useState } from "react";
import toast from "react-hot-toast";
import { getPersonIdentifier } from "@formbricks/lib/people/helpers";
import { TTag } from "@formbricks/types/v1/tags";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TProfile } from "@formbricks/types/v1/profile";
export interface SingleResponseCardProps {
survey: TSurvey;
response: TResponse;
profile: TProfile;
pageType: string;
environmentTags: TTag[];
environment: TEnvironment;
}
interface TooltipRendererProps {
shouldRender: boolean;
tooltipContent: ReactNode;
children: ReactNode;
}
function TooltipRenderer(props: TooltipRendererProps) {
const { children, shouldRender, tooltipContent } = props;
if (shouldRender) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>{children}</TooltipTrigger>
<TooltipContent>{tooltipContent}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return <>{children}</>;
}
export default function SingleResponseCard({
survey,
response,
profile,
pageType,
environmentTags,
environment,
}: SingleResponseCardProps) {
const environmentId = survey.environmentId;
const router = useRouter();
const displayIdentifier = response.person ? getPersonIdentifier(response.person) : null;
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const isSubmissionFresh = isSubmissionTimeLessThan5Minutes(response.updatedAt);
let skippedQuestions: string[][] = [];
let temp: string[] = [];
function isValidValue(value: any) {
return (
(typeof value === "string" && value.trim() !== "") ||
(Array.isArray(value) && value.length > 0) ||
typeof value === "number"
);
}
if (response.finished) {
survey.questions.forEach((question) => {
if (!response.data[question.id]) {
temp.push(question.id);
} else {
if (temp.length > 0) {
skippedQuestions.push([...temp]);
temp = [];
}
}
});
} else {
for (let index = survey.questions.length - 1; index >= 0; index--) {
const question = survey.questions[index];
if (!response.data[question.id]) {
if (skippedQuestions.length === 0) {
temp.push(question.id);
} else if (skippedQuestions.length > 0 && !isValidValue(response.data[question.id])) {
temp.push(question.id);
}
} else {
if (temp.length > 0) {
temp.reverse();
skippedQuestions.push([...temp]);
temp = [];
}
}
}
}
// Handle the case where the last entries are empty
if (temp.length > 0) {
skippedQuestions.push(temp);
}
function handleArray(data: string | number | string[]): string {
if (Array.isArray(data)) {
return data.join(", ");
} else {
return String(data);
}
}
const handleDeleteSubmission = async () => {
setIsDeleting(true);
try {
await deleteResponseAction(response.id);
router.refresh();
toast.success("Submission deleted successfully.");
setDeleteDialogOpen(false);
} catch (error) {
if (error instanceof Error) toast.error(error.message);
} finally {
setIsDeleting(false);
}
};
const renderTooltip = Boolean(
(response.personAttributes && Object.keys(response.personAttributes).length > 0) ||
(response.meta?.userAgent && Object.keys(response.meta.userAgent).length > 0)
);
function isSubmissionTimeLessThan5Minutes(submissionTimeISOString: Date) {
const submissionTime: Date = new Date(submissionTimeISOString);
const currentTime: Date = new Date();
const timeDifference: number = (currentTime.getTime() - submissionTime.getTime()) / (1000 * 60); // Convert milliseconds to minutes
return timeDifference < 5;
}
const tooltipContent = (
<>
{response.singleUseId && (
<div>
<p className="py-1 font-bold text-slate-700">SingleUse ID:</p>
<span>{response.singleUseId}</span>
</div>
)}
{response.personAttributes && Object.keys(response.personAttributes).length > 0 && (
<div>
<p className="py-1 font-bold text-slate-700">Person attributes:</p>
{Object.keys(response.personAttributes).map((key) => (
<p key={key}>
{key}:{" "}
<span className="font-bold">{response.personAttributes && response.personAttributes[key]}</span>
</p>
))}
</div>
)}
{response.meta?.userAgent && Object.keys(response.meta.userAgent).length > 0 && (
<div className="text-slate-600">
{response.personAttributes && Object.keys(response.personAttributes).length > 0 && (
<hr className="my-2 border-slate-200" />
)}
<p className="py-1 font-bold text-slate-700">Device info:</p>
{response.meta?.userAgent?.browser && <p>Browser: {response.meta.userAgent.browser}</p>}
{response.meta?.userAgent?.os && <p>OS: {response.meta.userAgent.os}</p>}
{response.meta?.userAgent && (
<p>
Device:{" "}
{response.meta.userAgent.device ? response.meta.userAgent.device : "PC / Generic device"}
</p>
)}
</div>
)}
</>
);
const deleteSubmissionToolTip = <>This submission is in progress.</>;
return (
<div className={clsx("group relative", isOpen && "min-h-[300px]")}>
<div
className={clsx(
"relative z-10 my-6 rounded-lg border border-slate-200 bg-slate-50 shadow-sm transition-all",
pageType === "response" &&
(isOpen ? "w-3/4" : response.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">
{pageType === "response" && (
<div>
{response.person?.id ? (
<Link
className="group flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<TooltipRenderer shouldRender={renderTooltip} tooltipContent={tooltipContent}>
<PersonAvatar personId={response.person.id} />
</TooltipRenderer>
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600 hover:underline">
{displayIdentifier}
</h3>
</Link>
) : (
<div className="group flex items-center">
<TooltipRenderer shouldRender={renderTooltip} tooltipContent={tooltipContent}>
<PersonAvatar personId="anonymous" />
</TooltipRenderer>
<h3 className="ml-4 pb-1 font-semibold text-slate-600">Anonymous</h3>
</div>
)}
</div>
)}
{pageType === "people" && (
<div className="flex items-center justify-center space-x-2 rounded-full bg-slate-100 p-1 px-2 text-sm text-slate-600">
{(survey.type === "link" || environment.widgetSetupCompleted) && (
<SurveyStatusIndicator status={survey.status} />
)}
<Link
className="hover:underline"
href={`/environments/${environmentId}/surveys/${survey.id}/summary`}>
{survey.name}
</Link>
</div>
)}
<div className="flex space-x-4 text-sm">
<time className="text-slate-500" dateTime={timeSince(response.updatedAt.toISOString())}>
{timeSince(response.updatedAt.toISOString())}
</time>
<TooltipRenderer shouldRender={isSubmissionFresh} tooltipContent={deleteSubmissionToolTip}>
<TrashIcon
onClick={() => {
if (!isSubmissionFresh) {
setDeleteDialogOpen(true);
}
}}
className={`h-4 w-4 ${
isSubmissionFresh
? "cursor-not-allowed text-gray-400"
: "text-slate-500 hover:text-red-700"
} `}
/>
</TooltipRenderer>
</div>
</div>
</div>
<div className="space-y-6 rounded-b-lg bg-white p-6">
{survey.questions.map((question) => {
const skipped = skippedQuestions.find((skippedQuestionElement) =>
skippedQuestionElement.includes(question.id)
);
// If found, remove it from the list
if (skipped) {
skippedQuestions = skippedQuestions.filter((item) => item !== skipped);
}
return (
<div key={`${question.id}`}>
{isValidValue(response.data[question.id]) ? (
<p className="text-sm text-slate-500">{question.headline}</p>
) : (
<QuestionSkip
skippedQuestions={skipped}
questions={survey.questions}
status={
response.finished ||
(skippedQuestions.length > 0 &&
!skippedQuestions[skippedQuestions.length - 1].includes(question.id))
? "skipped"
: "aborted"
}
/>
)}
{typeof response.data[question.id] !== "object" ? (
question.type === QuestionType.Rating ? (
<div>
<RatingResponse
scale={question.scale}
answer={response.data[question.id]}
range={question.range}
/>
</div>
) : (
<p className="ph-no-capture my-1 font-semibold text-slate-700">
{response.data[question.id]}
</p>
)
) : (
<p className="ph-no-capture my-1 font-semibold text-slate-700">
{handleArray(response.data[question.id])}
</p>
)}
</div>
);
})}
{response.finished && (
<div className="flex">
<CheckCircleIcon className="h-6 w-6 text-slate-400" />
<p className="mx-2 rounded-lg bg-slate-100 px-2 text-slate-700">Completed</p>
</div>
)}
</div>
<ResponseTagsWrapper
environmentId={environmentId}
responseId={response.id}
tags={response.tags.map((tag) => ({ tagId: tag.id, tagName: tag.name }))}
environmentTags={environmentTags}
/>
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
deleteWhat="response"
onDelete={handleDeleteSubmission}
isDeleting={isDeleting}
/>
</div>
{pageType === "response" && (
<ResponseNotes
profile={profile}
responseId={response.id}
notes={response.notes}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>
)}
</div>
);
}

View File

@@ -1,33 +1,14 @@
"use client";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
import { ArchiveBoxIcon, CheckIcon, PauseIcon, ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "..";
import { ArchiveBoxIcon, CheckIcon, PauseIcon } from "@heroicons/react/24/solid";
interface SurveyStatusIndicatorProps {
status: string;
tooltip?: boolean;
environment: TEnvironment;
type: string;
}
export default function SurveyStatusIndicator({
status,
tooltip,
environment,
type,
}: SurveyStatusIndicatorProps) {
if (!environment.widgetSetupCompleted) {
if (type === "web") {
return (
<div>
<ExclamationTriangleIcon className="h-4 w-4 text-amber-500" />
</div>
);
} else {
return null;
}
}
export function SurveyStatusIndicator({ status, tooltip }: SurveyStatusIndicatorProps) {
if (tooltip) {
return (
<TooltipProvider>

View File

@@ -74,6 +74,10 @@ export { Switch } from "./components/Switch";
export { TabBar } from "./components/TabBar";
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./components/Tooltip";
export { AddVariablesDropdown, Editor } from "./components/editor";
export { RatingResponse } from "./SingleResponseCard/components/RatingResponse";
export { DeleteDialog } from "./DeleteDialog";
export { Modal } from "./Modal";
export { SurveyStatusIndicator } from "./SurveyStatusIndicator";
export { QuestionTypeSelector } from "./components/QuestionTypeSelector";
/* Icons */

View File

@@ -2,18 +2,14 @@
"name": "@formbricks/ui",
"private": true,
"sideEffects": false,
"version": "0.0.0",
"version": "1.0.0",
"main": "./index.tsx",
"exports": {
".": "./index.tsx",
"./components/icon": "./components/icon/index.ts"
},
"types": "./index.tsx",
"scripts": {
"clean": "rimraf .turbo node_modules dist"
},
"devDependencies": {
"@formbricks/tsconfig": "workspace:*",
"@formbricks/types": "workspace:*",
"concurrently": "^8.2.1",
"eslint-config-formbricks": "workspace:*",
"postcss": "^8.4.31",
@@ -49,6 +45,7 @@
"react-confetti": "^6.1.0",
"react-day-picker": "^8.8.2",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-radio-group": "^3.0.3",
"react-use": "^17.4.0"
}

View File

@@ -1,5 +1,5 @@
{
"extends": "@formbricks/tsconfig/react-library.json",
"include": ["."],
"exclude": ["dist", "build", "node_modules"]
"exclude": ["build", "node_modules"]
}

82
pnpm-lock.yaml generated
View File

@@ -28,7 +28,7 @@ importers:
version: 3.13.0
turbo:
specifier: latest
version: 1.10.12
version: 1.10.13
apps/demo:
dependencies:
@@ -459,7 +459,7 @@ importers:
version: 9.0.0(eslint@8.50.0)
eslint-config-turbo:
specifier: latest
version: 1.10.12(eslint@8.50.0)
version: 1.8.8(eslint@8.50.0)
eslint-plugin-react:
specifier: 7.33.2
version: 7.33.2(eslint@8.50.0)
@@ -792,6 +792,9 @@ importers:
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
react-hot-toast:
specifier: ^2.4.1
version: 2.4.1(csstype@3.1.1)(react-dom@18.2.0)(react@18.2.0)
react-radio-group:
specifier: ^3.0.3
version: 3.0.3(react-dom@18.2.0)(react@18.2.0)
@@ -802,6 +805,9 @@ importers:
'@formbricks/tsconfig':
specifier: workspace:*
version: link:../tsconfig
'@formbricks/types':
specifier: workspace:*
version: link:../types
concurrently:
specifier: ^8.2.1
version: 8.2.1
@@ -10229,7 +10235,7 @@ packages:
normalize-path: 3.0.0
readdirp: 3.6.0
optionalDependencies:
fsevents: 2.3.2
fsevents: 2.3.3
/chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
@@ -12512,13 +12518,13 @@ packages:
resolution: {integrity: sha512-NB/L/1Y30qyJcG5xZxCJKW/+bqyj+llbcCwo9DEz8bESIP0SLTOQ8T1DWCCFc+wJ61AMEstj4511PSScqMMfCw==}
dev: true
/eslint-config-turbo@1.10.12(eslint@8.50.0):
resolution: {integrity: sha512-z3jfh+D7UGYlzMWGh+Kqz++hf8LOE96q3o5R8X4HTjmxaBWlLAWG+0Ounr38h+JLR2TJno0hU9zfzoPNkR9BdA==}
/eslint-config-turbo@1.8.8(eslint@8.50.0):
resolution: {integrity: sha512-+yT22sHOT5iC1sbBXfLIdXfbZuiv9bAyOXsxTxFCWelTeFFnANqmuKB3x274CFvf7WRuZ/vYP/VMjzU9xnFnxA==}
peerDependencies:
eslint: '>6.6.0'
dependencies:
eslint: 8.50.0
eslint-plugin-turbo: 1.10.12(eslint@8.50.0)
eslint-plugin-turbo: 1.8.8(eslint@8.50.0)
dev: true
/eslint-import-resolver-node@0.3.9:
@@ -12724,12 +12730,11 @@ packages:
semver: 6.3.1
string.prototype.matchall: 4.0.8
/eslint-plugin-turbo@1.10.12(eslint@8.50.0):
resolution: {integrity: sha512-uNbdj+ohZaYo4tFJ6dStRXu2FZigwulR1b3URPXe0Q8YaE7thuekKNP+54CHtZPH9Zey9dmDx5btAQl9mfzGOw==}
/eslint-plugin-turbo@1.8.8(eslint@8.50.0):
resolution: {integrity: sha512-zqyTIvveOY4YU5jviDWw9GXHd4RiKmfEgwsjBrV/a965w0PpDwJgEUoSMB/C/dU310Sv9mF3DSdEjxjJLaw6rA==}
peerDependencies:
eslint: '>6.6.0'
dependencies:
dotenv: 16.0.3
eslint: 8.50.0
dev: true
@@ -13696,19 +13701,11 @@ packages:
dev: true
optional: true
/fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
requiresBuild: true
optional: true
/fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
requiresBuild: true
dev: true
optional: true
/fstream@1.0.12:
@@ -15868,7 +15865,7 @@ packages:
micromatch: 4.0.5
walker: 1.0.8
optionalDependencies:
fsevents: 2.3.2
fsevents: 2.3.3
dev: true
/jest-leak-detector@29.7.0:
@@ -22204,7 +22201,7 @@ packages:
engines: {node: '>=10.0.0'}
hasBin: true
optionalDependencies:
fsevents: 2.3.2
fsevents: 2.3.3
dev: false
/rollup@2.79.1:
@@ -22212,7 +22209,7 @@ packages:
engines: {node: '>=10.0.0'}
hasBin: true
optionalDependencies:
fsevents: 2.3.2
fsevents: 2.3.3
dev: true
/rollup@3.29.4:
@@ -22220,7 +22217,7 @@ packages:
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
hasBin: true
optionalDependencies:
fsevents: 2.3.2
fsevents: 2.3.3
dev: true
/rtl-css-js@1.16.1:
@@ -24216,65 +24213,64 @@ packages:
dependencies:
safe-buffer: 5.2.1
/turbo-darwin-64@1.10.12:
resolution: {integrity: sha512-vmDfGVPl5/aFenAbOj3eOx3ePNcWVUyZwYr7taRl0ZBbmv2TzjRiFotO4vrKCiTVnbqjQqAFQWY2ugbqCI1kOQ==}
/turbo-darwin-64@1.10.13:
resolution: {integrity: sha512-vmngGfa2dlYvX7UFVncsNDMuT4X2KPyPJ2Jj+xvf5nvQnZR/3IeDEGleGVuMi/hRzdinoxwXqgk9flEmAYp0Xw==}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/turbo-darwin-arm64@1.10.12:
resolution: {integrity: sha512-3JliEESLNX2s7g54SOBqqkqJ7UhcOGkS0ywMr5SNuvF6kWVTbuUq7uBU/sVbGq8RwvK1ONlhPvJne5MUqBCTCQ==}
/turbo-darwin-arm64@1.10.13:
resolution: {integrity: sha512-eMoJC+k7gIS4i2qL6rKmrIQGP6Wr9nN4odzzgHFngLTMimok2cGLK3qbJs5O5F/XAtEeRAmuxeRnzQwTl/iuAw==}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/turbo-linux-64@1.10.12:
resolution: {integrity: sha512-siYhgeX0DidIfHSgCR95b8xPee9enKSOjCzx7EjTLmPqPaCiVebRYvbOIYdQWRqiaKh9yfhUtFmtMOMScUf1gg==}
/turbo-linux-64@1.10.13:
resolution: {integrity: sha512-0CyYmnKTs6kcx7+JRH3nPEqCnzWduM0hj8GP/aodhaIkLNSAGAa+RiYZz6C7IXN+xUVh5rrWTnU2f1SkIy7Gdg==}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/turbo-linux-arm64@1.10.12:
resolution: {integrity: sha512-K/ZhvD9l4SslclaMkTiIrnfcACgos79YcAo4kwc8bnMQaKuUeRpM15sxLpZp3xDjDg8EY93vsKyjaOhdFG2UbA==}
/turbo-linux-arm64@1.10.13:
resolution: {integrity: sha512-0iBKviSGQQlh2OjZgBsGjkPXoxvRIxrrLLbLObwJo3sOjIH0loGmVIimGS5E323soMfi/o+sidjk2wU1kFfD7Q==}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/turbo-windows-64@1.10.12:
resolution: {integrity: sha512-7FSgSwvktWDNOqV65l9AbZwcoueAILeE4L7JvjauNASAjjbuzXGCEq5uN8AQU3U5BOFj4TdXrVmO2dX+lLu8Zg==}
/turbo-windows-64@1.10.13:
resolution: {integrity: sha512-S5XySRfW2AmnTeY1IT+Jdr6Goq7mxWganVFfrmqU+qqq3Om/nr0GkcUX+KTIo9mPrN0D3p5QViBRzulwB5iuUQ==}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/turbo-windows-arm64@1.10.12:
resolution: {integrity: sha512-gCNXF52dwom1HLY9ry/cneBPOKTBHhzpqhMylcyvJP0vp9zeMQQkt6yjYv+6QdnmELC92CtKNp2FsNZo+z0pyw==}
/turbo-windows-arm64@1.10.13:
resolution: {integrity: sha512-nKol6+CyiExJIuoIc3exUQPIBjP9nIq5SkMJgJuxsot2hkgGrafAg/izVDRDrRduQcXj2s8LdtxJHvvnbI8hEQ==}
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/turbo@1.10.12:
resolution: {integrity: sha512-WM3+jTfQWnB9W208pmP4oeehZcC6JQNlydb/ZHMRrhmQa+htGhWLCzd6Q9rLe0MwZLPpSPFV2/bN5egCLyoKjQ==}
/turbo@1.10.13:
resolution: {integrity: sha512-vOF5IPytgQPIsgGtT0n2uGZizR2N3kKuPIn4b5p5DdeLoI0BV7uNiydT7eSzdkPRpdXNnO8UwS658VaI4+YSzQ==}
hasBin: true
requiresBuild: true
optionalDependencies:
turbo-darwin-64: 1.10.12
turbo-darwin-arm64: 1.10.12
turbo-linux-64: 1.10.12
turbo-linux-arm64: 1.10.12
turbo-windows-64: 1.10.12
turbo-windows-arm64: 1.10.12
turbo-darwin-64: 1.10.13
turbo-darwin-arm64: 1.10.13
turbo-linux-64: 1.10.13
turbo-linux-arm64: 1.10.13
turbo-windows-64: 1.10.13
turbo-windows-arm64: 1.10.13
dev: true
/tw-to-css@0.0.11:
@@ -25172,7 +25168,7 @@ packages:
rollup: 3.29.4
terser: 5.21.0
optionalDependencies:
fsevents: 2.3.2
fsevents: 2.3.3
dev: true
/vm-browserify@1.1.2: