mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-29 10:12:17 -05:00
Merge branch 'main' of https://github.com/gupta-piyush19/formbricks into feat/close-date-edge-case
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2023 Matthias Nannt, Johannes Dancker
|
||||
Copyright (c) 2023 Formbricks GmbH
|
||||
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import Image from "next/image";
|
||||
import AuthorJohannes from "@/images/blog/johannes-co-founder-formbricks-small.jpg";
|
||||
|
||||
interface AuthorBoxProps {
|
||||
name: string;
|
||||
title: string;
|
||||
date: string;
|
||||
duration: string;
|
||||
}
|
||||
|
||||
export default function AuthorBox({ name, title, date, duration }: AuthorBoxProps) {
|
||||
return (
|
||||
<div className="mb-8 flex items-center space-x-4 rounded-lg border border-slate-200 bg-slate-100 px-6 py-3 dark:border-slate-700 dark:bg-slate-800">
|
||||
<Image
|
||||
className="m-0 rounded-full"
|
||||
src={AuthorJohannes}
|
||||
alt={name}
|
||||
width={45}
|
||||
height={45}
|
||||
quality={100}
|
||||
placeholder="blur"
|
||||
style={{ objectFit: "contain" }}
|
||||
/>
|
||||
<div className="flex w-full items-end justify-between">
|
||||
<div>
|
||||
<p className="m-0 font-medium text-slate-600 dark:text-slate-300">{name}</p>
|
||||
<p className="m-0 text-sm text-slate-400">{title}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="m-0 font-medium text-slate-600 dark:text-slate-300">{duration} Minutes</p>
|
||||
<p className="m-0 text-sm text-slate-400">{date}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
import { Icon } from "@/components/shared/Icon";
|
||||
|
||||
const styles = {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -6,6 +6,7 @@ import LimeSurvey from "./free-survey-tool-limesurvey-open-source-software-opens
|
||||
import OpnForm from "./opnform-free-open-source-form-survey-tools-builder-2023-self-hostign.jpg";
|
||||
import HeaderImage from "./2023-title-best-open-source-survey-software-tools-and-alternatives.png";
|
||||
import SurveyJS from "./surveyjs-free-opensource-form-survey-tool-software-to-make-surveys-2023.png";
|
||||
import AuthorBox from "@/components/shared/AuthorBox";
|
||||
|
||||
export const meta = {
|
||||
title: "Best Open-source Form & Survey Tools (still maintained in 2023)",
|
||||
@@ -14,6 +15,8 @@ export const meta = {
|
||||
date: "2023-04-12",
|
||||
};
|
||||
|
||||
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />
|
||||
|
||||
_Most open-source projects get abandoned after a while. But these 5 open-source form and survey tools are still alive and kicking in 2023._
|
||||
|
||||
<Image
|
||||
|
||||
@@ -5,6 +5,7 @@ import Demo from "./our-experience-github-acc-demo-screenshot.png";
|
||||
import Mail from "./github-accelerator-selection-mail.png";
|
||||
import Teams from "./github-accelerator-2022-teams.png";
|
||||
import NewsletterSignup from "@/components/shared/NewsletterSignup";
|
||||
import AuthorBox from "@/components/shared/AuthorBox";
|
||||
|
||||
export const meta = {
|
||||
title: "Our GitHub Accelerator Experience 👀",
|
||||
@@ -13,6 +14,8 @@ export const meta = {
|
||||
date: "2023-04-13",
|
||||
};
|
||||
|
||||
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />
|
||||
|
||||
_We were among the first 20 teams ever to run through the Open-Source Accelerator by Github. Read about our experience and if we would do it again:_
|
||||
|
||||
<Image
|
||||
|
||||
@@ -2,6 +2,7 @@ import Image from "next/image";
|
||||
import LayoutMdx from "@/components/shared/LayoutMdx";
|
||||
import TitleImage from "./formbricks-sponsored-by-github-accelerator-2023.webp";
|
||||
import NewsletterSignup from "@/components/shared/NewsletterSignup";
|
||||
import AuthorBox from "@/components/shared/AuthorBox";
|
||||
|
||||
export const meta = {
|
||||
title: "Formbricks Joins GitHub Accelerator's Inaugural Cohort 💃",
|
||||
@@ -10,6 +11,8 @@ export const meta = {
|
||||
date: "2023-04-13",
|
||||
};
|
||||
|
||||
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />
|
||||
|
||||
_We're getting ready to take our open-source experience management platform to new heights, thanks to being part of the first-ever GitHub Accelerator program!_
|
||||
|
||||
<Image
|
||||
|
||||
@@ -4,6 +4,7 @@ import RobinHoodMeme from "./robin-hood-meme.png";
|
||||
import WhyWeDoIt from "./why-we-do-it.png";
|
||||
import EverythinEverywhereAllAtOnce from "./everything_everywhere_all_at_once.png";
|
||||
import ResponsiveEmbed from "react-responsive-embed";
|
||||
import AuthorBox from "@/components/shared/AuthorBox";
|
||||
|
||||
export const meta = {
|
||||
title: "Open source forms will save the world.",
|
||||
@@ -11,6 +12,8 @@ export const meta = {
|
||||
date: "2022-08-26",
|
||||
};
|
||||
|
||||
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />
|
||||
|
||||
<Image src={RobinHoodMeme} alt="Robin Hood Meme" className="rounded-lg" />
|
||||
|
||||
_What motivates us to build open source tech in such a crowded space? What do we see what others might not? And how do we understand the relationship between free open source tech and a commercial complement?_
|
||||
|
||||
@@ -9,6 +9,7 @@ import Wrestling from "./wrestling.jpg";
|
||||
import TypeformValue from "./typeform-value-prop.png";
|
||||
import ResponsiveEmbed from "react-responsive-embed";
|
||||
import { Callout } from "@/components/shared/Callout";
|
||||
import AuthorBox from "@/components/shared/AuthorBox";
|
||||
|
||||
export const meta = {
|
||||
title: "Why Qualtrics beats Typeform, especially Open-Source",
|
||||
@@ -17,6 +18,8 @@ export const meta = {
|
||||
date: "2023-03-24",
|
||||
};
|
||||
|
||||
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />
|
||||
|
||||
<Image src={Wrestling} alt="Why we do it" className="rounded-lg" />
|
||||
|
||||
_In September, we kicked it off with a Typeform open-source alternative. As we build and learn, our focus is shifting. We talk about how we look at form and survey tools today, why experience management not only matters for enterprise and why the endgame looks a lot more like open-source Qualtrics. Qualtrics? What happened to Typeform?_
|
||||
|
||||
@@ -2,6 +2,7 @@ import Image from "next/image";
|
||||
import LayoutMdx from "@/components/shared/LayoutMdx";
|
||||
import HeaderImage from "./formbricks-logo-header-open-source-form-infrastructure.svg";
|
||||
import HeroAnimation from "../../../components/shared/HeroAnimation.tsx";
|
||||
import AuthorBox from "@/components/shared/AuthorBox";
|
||||
|
||||
export const meta = {
|
||||
title: "snoopForms → Formbricks 🎉",
|
||||
@@ -9,6 +10,8 @@ export const meta = {
|
||||
date: "2022-11-07",
|
||||
};
|
||||
|
||||
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />
|
||||
|
||||
<Image src={HeaderImage} alt="Formbricks - Open Source Forms and Surveys" className="rounded-lg" />
|
||||
|
||||
_It has been quiet in the past weeks, but we didn't spend our days sitting around. Find out what we were up to and where we are taking Formbricks from here._
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Image from "next/image";
|
||||
import LayoutMdx from "@/components/shared/LayoutMdx";
|
||||
import { Callout } from "@/components/shared/Callout";
|
||||
import AuthorBox from "@/components/shared/AuthorBox";
|
||||
|
||||
import TweetPeer from "./peer-tweet-typeform-open-source.png";
|
||||
import SnoopForms from "./snoopforms-open-source-typeform-alternative.png";
|
||||
@@ -17,6 +18,8 @@ export const meta = {
|
||||
date: "2023-07-14",
|
||||
};
|
||||
|
||||
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />
|
||||
|
||||
_A lot has happened since Matti and I had a chat about open-source surveys in May last year. The release of Formbricks v1.0 is a perfect opportunity to look back on how it all started._
|
||||
|
||||
Funnily enough, it started with a tweet:
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 976 KiB |
@@ -1,47 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import LayoutMdx from "@/components/shared/LayoutMdx";
|
||||
import FormbricksSneak from "./formbricks-sneak.png";
|
||||
import ResponsiveEmbed from "react-responsive-embed";
|
||||
|
||||
export const meta = {
|
||||
title: "Video: Walk-through of the new Formbricks",
|
||||
description: "The new, powerful Formbricks is almost ready!",
|
||||
date: "2023-03-30",
|
||||
};
|
||||
|
||||
_The new, powerful Formbricks is almost ready!_
|
||||
|
||||
<Image src={FormbricksSneak} alt="Sneakpeek into what the new Formbricks can do" className="rounded-lg" />
|
||||
|
||||
We've been working hard on getting a revamped Formbricks ready - we're almost there!
|
||||
|
||||
What you can do with it:
|
||||
|
||||
1. Design **any survey** you want
|
||||
2. Trigger at any point in your app both **No Code** (page view, element click) and **Code** (hook `formbricks.track` into your event)
|
||||
3. Pass custom user attributes to Formbricks to **segment your user base**
|
||||
|
||||
## Have a look:
|
||||
|
||||
<ResponsiveEmbed
|
||||
src="https://www.tella.tv/video/clfrymq2f00sk0fjqd9r6btf1/embed?b=0&title=0&a=1&loop=0&t=0&muted=0"
|
||||
allowFullScreen
|
||||
className="rounded-lg"
|
||||
/>
|
||||
|
||||
Formbricks is a lot more powerful than ever before! :mechanical_arm:
|
||||
|
||||
You can create:
|
||||
|
||||
- Onboarding surveys,
|
||||
- PMF surveys,
|
||||
- Churn surveys,
|
||||
- Feature chaser,
|
||||
- Feedback box,
|
||||
- Identify customer goals,
|
||||
- Measure task completion,
|
||||
- etc, etc.
|
||||
|
||||
## Stay tuned, Formbricks Cloud goes live soon!
|
||||
|
||||
export default ({ children }) => <LayoutMdx meta={meta}>{children}</LayoutMdx>;
|
||||
+3
@@ -6,6 +6,7 @@ import TitleImage from "./title-image.png";
|
||||
import HeaderImage from "./formbricks-logo.svg";
|
||||
import ProprietaryDependence from "./propietary-dependence.jpeg";
|
||||
import ResponsiveEmbed from "react-responsive-embed";
|
||||
import AuthorBox from "@/components/shared/AuthorBox";
|
||||
|
||||
export const meta = {
|
||||
title: "Why Open-Source + No-Code is the Future of Enterprise & Goverment Software",
|
||||
@@ -14,6 +15,8 @@ export const meta = {
|
||||
date: "2022-06-03",
|
||||
};
|
||||
|
||||
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />
|
||||
|
||||
<Image src={TitleImage} alt="Title Image" className="rounded-lg" />
|
||||
|
||||
_Open source software (OSS) beats out proprietary software in every regard - except for value capturing. No-Code tools shorten the feedback loop between builders and consumers, kicking productivity through the roof. Here is why a no-code interface is cheatcode for OSS and why particularly large corporations and governments are to benefit the most._
|
||||
|
||||
@@ -3,12 +3,13 @@ import Layout from "@/components/shared/Layout";
|
||||
import Cal, { getCalApi } from "@calcom/embed-react";
|
||||
import { useEffect } from "react";
|
||||
import { CheckBadgeIcon } from "@heroicons/react/24/solid";
|
||||
import { Button } from "@formbricks/ui";
|
||||
|
||||
const XMOffer = [
|
||||
{
|
||||
step: "1",
|
||||
header: "Kick-off call",
|
||||
description: "You share with our seasoned PMs which areas of your customer experience need improvement.",
|
||||
header: "Kick-off call (FREE)",
|
||||
description: "Share with our seasoned PMs which areas of customer experience need improvement.",
|
||||
},
|
||||
{
|
||||
step: "2",
|
||||
@@ -69,20 +70,29 @@ const ConciergePage = () => {
|
||||
</div>
|
||||
))}
|
||||
<div className="border-b border-t border-slate-300 p-6 text-4xl font-semibold text-slate-800">
|
||||
<p className="mr-2 font-light">$2.290</p>
|
||||
<p className="mr-2 font-light">$1.290</p>
|
||||
</div>
|
||||
<div className="p-6 text-sm text-slate-800">
|
||||
<p>
|
||||
<CheckBadgeIcon className="mr-1 inline h-5 w-5 text-slate-800" />
|
||||
100% Risk-free: Pay after the kick-off call.
|
||||
100% Risk-free: Pay after the kick-off call, if you liked it.
|
||||
</p>
|
||||
<p>
|
||||
<CheckBadgeIcon className="mr-1 inline h-5 w-5 text-slate-800" />
|
||||
Money-back: If you're not happy, get a full refund.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6">
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
className="w-full justify-center"
|
||||
href="https://cal.com/johannes/kick-off"
|
||||
target="_blank">
|
||||
Book free Kick-Off call
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl">
|
||||
<div className="!mt-0 rounded-xl">
|
||||
<Cal
|
||||
calLink="johannes/kick-off"
|
||||
style={{
|
||||
|
||||
@@ -39,7 +39,7 @@ export const meta = {
|
||||
},
|
||||
]}
|
||||
example={`{
|
||||
"personId: "clfqjny0v000ayzgsycx54a2c",
|
||||
"personId": "clfqjny0v000ayzgsycx54a2c",
|
||||
"surveyId": "clfqz1esd0000yzah51trddn8",
|
||||
"finished": true,
|
||||
"data": {
|
||||
|
||||
@@ -45,18 +45,18 @@ import formbricks from "@formbricks/js";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
formbricks.init({
|
||||
environmentId: "clj66eqzu00m5qu0g8leglrns",
|
||||
apiHost: "https://app.formbricks.com", // e.g. https://app.formbricks.com
|
||||
debug: true, // remove when in production
|
||||
});
|
||||
}
|
||||
|
||||
export default function FormbricksProvider() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
formbricks.init({
|
||||
environmentId: "clj66eqzu00m5qu0g8leglrns",
|
||||
apiHost: "https://app.formbricks.com", // e.g. https://app.formbricks.com
|
||||
debug: true, // remove when in production
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
formbricks?.registerRouteChange();
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
@@ -6,9 +6,8 @@ export const meta = {
|
||||
|
||||
## Information according to § 5 TMG
|
||||
|
||||
Johannes Dancker & Matthias Nannt\
|
||||
Formbricks GmbH\
|
||||
Kuhnkestr. 6\
|
||||
c/o Starterkitchen\
|
||||
24118 Kiel\
|
||||
Germany
|
||||
|
||||
|
||||
@@ -114,9 +114,8 @@ Please use the following contact information for privacy inquiries:
|
||||
|
||||
privacy@formbricks.com
|
||||
|
||||
Johannes Dancker & Matthias Nannt<br/>
|
||||
Formbricks GmbH<br/>
|
||||
Kuhnkestr. 6<br/>
|
||||
c/o Starterkitchen<br/>
|
||||
24118 Kiel<br/>
|
||||
Germany
|
||||
|
||||
|
||||
@@ -17,8 +17,7 @@ export default function ConfirmationPage() {
|
||||
<div className="my-6 sm:flex-auto">
|
||||
<h1 className="text-center text-xl font-semibold text-slate-900">Upgrade successful</h1>
|
||||
<p className="mt-2 text-center text-sm text-slate-700">
|
||||
Thanks a lot for upgrading your formbricks subscription. You can now access all features and
|
||||
improve your user research.
|
||||
Thanks a lot for upgrading your Formbricks subscription. You have now unlimited access.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="darkCTA" className="w-full justify-center" href="/">
|
||||
|
||||
@@ -166,7 +166,7 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
|
||||
icon: CreditCardIcon,
|
||||
label: "Billing & Plan",
|
||||
href: `/environments/${environmentId}/settings/billing`,
|
||||
hidden: IS_FORMBRICKS_CLOUD,
|
||||
hidden: !IS_FORMBRICKS_CLOUD,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { Team } from "@prisma/client";
|
||||
import { ResourceNotFoundError } from "@formbricks/errors";
|
||||
import { deleteSurvey, getSurvey } from "@formbricks/lib/services/survey";
|
||||
import { QuestionType } from "@formbricks/types/questions";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Team } from "@prisma/client";
|
||||
import { Prisma as prismaClient } from "@prisma/client/";
|
||||
|
||||
export async function createTeam(teamName: string, ownerUserId: string): Promise<Team> {
|
||||
const newTeam = await prisma.team.create({
|
||||
@@ -1372,3 +1375,176 @@ export async function addDemoData(teamId: string): Promise<void> {
|
||||
InterviewPromptResults.displays
|
||||
);
|
||||
}
|
||||
|
||||
export async function duplicateSurveyAction(environmentId: string, surveyId: string) {
|
||||
const existingSurvey = await getSurvey(surveyId);
|
||||
|
||||
if (!existingSurvey) {
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
// create new survey with the data of the existing survey
|
||||
const newSurvey = await prisma.survey.create({
|
||||
data: {
|
||||
...existingSurvey,
|
||||
id: undefined, // id is auto-generated
|
||||
environmentId: undefined, // environmentId is set below
|
||||
name: `${existingSurvey.name} (copy)`,
|
||||
status: "draft",
|
||||
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
|
||||
thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)),
|
||||
triggers: {
|
||||
create: existingSurvey.triggers.map((trigger) => ({
|
||||
eventClassId: trigger.id,
|
||||
})),
|
||||
},
|
||||
attributeFilters: {
|
||||
create: existingSurvey.attributeFilters.map((attributeFilter) => ({
|
||||
attributeClassId: attributeFilter.attributeClassId,
|
||||
condition: attributeFilter.condition,
|
||||
value: attributeFilter.value,
|
||||
})),
|
||||
},
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
surveyClosedMessage: existingSurvey.surveyClosedMessage
|
||||
? JSON.parse(JSON.stringify(existingSurvey.surveyClosedMessage))
|
||||
: prismaClient.JsonNull,
|
||||
},
|
||||
});
|
||||
return newSurvey;
|
||||
}
|
||||
|
||||
export async function copyToOtherEnvironmentAction(
|
||||
environmentId: string,
|
||||
surveyId: string,
|
||||
targetEnvironmentId: string
|
||||
) {
|
||||
const existingSurvey = await prisma.survey.findFirst({
|
||||
where: {
|
||||
id: surveyId,
|
||||
environmentId,
|
||||
},
|
||||
include: {
|
||||
triggers: {
|
||||
include: {
|
||||
eventClass: true,
|
||||
},
|
||||
},
|
||||
attributeFilters: {
|
||||
include: {
|
||||
attributeClass: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingSurvey) {
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
let targetEnvironmentTriggers: string[] = [];
|
||||
// map the local triggers to the target environment
|
||||
for (const trigger of existingSurvey.triggers) {
|
||||
const targetEnvironmentTrigger = await prisma.eventClass.findFirst({
|
||||
where: {
|
||||
name: trigger.eventClass.name,
|
||||
environment: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!targetEnvironmentTrigger) {
|
||||
// if the trigger does not exist in the target environment, create it
|
||||
const newTrigger = await prisma.eventClass.create({
|
||||
data: {
|
||||
name: trigger.eventClass.name,
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
description: trigger.eventClass.description,
|
||||
type: trigger.eventClass.type,
|
||||
noCodeConfig: trigger.eventClass.noCodeConfig
|
||||
? JSON.parse(JSON.stringify(trigger.eventClass.noCodeConfig))
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
targetEnvironmentTriggers.push(newTrigger.id);
|
||||
} else {
|
||||
targetEnvironmentTriggers.push(targetEnvironmentTrigger.id);
|
||||
}
|
||||
}
|
||||
|
||||
let targetEnvironmentAttributeFilters: string[] = [];
|
||||
// map the local attributeFilters to the target env
|
||||
for (const attributeFilter of existingSurvey.attributeFilters) {
|
||||
// check if attributeClass exists in target env.
|
||||
// if not, create it
|
||||
const targetEnvironmentAttributeClass = await prisma.attributeClass.findFirst({
|
||||
where: {
|
||||
name: attributeFilter.attributeClass.name,
|
||||
environment: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!targetEnvironmentAttributeClass) {
|
||||
const newAttributeClass = await prisma.attributeClass.create({
|
||||
data: {
|
||||
name: attributeFilter.attributeClass.name,
|
||||
description: attributeFilter.attributeClass.description,
|
||||
type: attributeFilter.attributeClass.type,
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
targetEnvironmentAttributeFilters.push(newAttributeClass.id);
|
||||
} else {
|
||||
targetEnvironmentAttributeFilters.push(targetEnvironmentAttributeClass.id);
|
||||
}
|
||||
}
|
||||
|
||||
// create new survey with the data of the existing survey
|
||||
const newSurvey = await prisma.survey.create({
|
||||
data: {
|
||||
...existingSurvey,
|
||||
id: undefined, // id is auto-generated
|
||||
environmentId: undefined, // environmentId is set below
|
||||
name: `${existingSurvey.name} (copy)`,
|
||||
status: "draft",
|
||||
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
|
||||
thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)),
|
||||
triggers: {
|
||||
create: targetEnvironmentTriggers.map((eventClassId) => ({
|
||||
eventClassId: eventClassId,
|
||||
})),
|
||||
},
|
||||
attributeFilters: {
|
||||
create: existingSurvey.attributeFilters.map((attributeFilter, idx) => ({
|
||||
attributeClassId: targetEnvironmentAttributeFilters[idx],
|
||||
condition: attributeFilter.condition,
|
||||
value: attributeFilter.value,
|
||||
})),
|
||||
},
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
|
||||
},
|
||||
});
|
||||
return newSurvey;
|
||||
}
|
||||
|
||||
export const deleteSurveyAction = async (surveyId: string) => {
|
||||
await deleteSurvey(surveyId);
|
||||
};
|
||||
|
||||
@@ -7,10 +7,11 @@ import type { Session } from "next-auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
// upated on 20th of July 2023
|
||||
const stripeURl =
|
||||
process.env.NODE_ENV === "production"
|
||||
? "https://buy.stripe.com/28o00R4GDf9qdfa5kp"
|
||||
: "https://buy.stripe.com/test_9AQfZw5CL9hmcXSdQQ";
|
||||
? "https://buy.stripe.com/5kA9ABal07ZjgEw3cc"
|
||||
: "https://buy.stripe.com/test_8wMaHA3UWcACfuM3cc";
|
||||
|
||||
interface PricingTableProps {
|
||||
environmentId: string;
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { getServerSession } from "next-auth";
|
||||
import SettingsCard from "../SettingsCard";
|
||||
import SettingsTitle from "../SettingsTitle";
|
||||
import PricingTable from "./PricingTable";
|
||||
|
||||
const proPlan = false;
|
||||
|
||||
export default async function ProfileSettingsPage({ params }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
return (
|
||||
<div>
|
||||
<SettingsTitle title="Billing & Plan" />
|
||||
{proPlan ? (
|
||||
<SettingsCard
|
||||
title="Manage subscription"
|
||||
description="View, update and cancel your subscription in the billing portal.">
|
||||
<Button variant="darkCTA">Billing Portal</Button>
|
||||
</SettingsCard>
|
||||
) : (
|
||||
<PricingTable environmentId={params.environmentId} session={session} />
|
||||
)}
|
||||
<PricingTable environmentId={params.environmentId} session={session} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
copyToOtherEnvironmentAction,
|
||||
deleteSurveyAction,
|
||||
duplicateSurveyAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import DeleteDialog from "@/components/shared/DeleteDialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/shared/DropdownMenu";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import type { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
|
||||
import {
|
||||
ArrowUpOnSquareStackIcon,
|
||||
DocumentDuplicateIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
EyeIcon,
|
||||
LinkIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface SurveyDropDownMenuProps {
|
||||
environmentId: string;
|
||||
survey: TSurveyWithAnalytics;
|
||||
environment: TEnvironment;
|
||||
otherEnvironment: TEnvironment;
|
||||
}
|
||||
|
||||
export default function SurveyDropDownMenu({
|
||||
environmentId,
|
||||
survey,
|
||||
environment,
|
||||
otherEnvironment,
|
||||
}: SurveyDropDownMenuProps) {
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleDeleteSurvey = async (survey) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteSurveyAction(survey.id);
|
||||
router.refresh();
|
||||
setDeleteDialogOpen(false);
|
||||
toast.success("Survey deleted successfully.");
|
||||
} catch (error) {
|
||||
toast.error("An error occured while deleting survey");
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const duplicateSurveyAndRefresh = async (surveyId) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await duplicateSurveyAction(environmentId, surveyId);
|
||||
router.refresh();
|
||||
toast.success("Survey duplicated successfully.");
|
||||
} catch (error) {
|
||||
toast.error("Failed to duplicate the survey.");
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const copyToOtherEnvironment = async (surveyId) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await copyToOtherEnvironmentAction(environmentId, surveyId, otherEnvironment.id);
|
||||
if (otherEnvironment.type === "production") {
|
||||
toast.success("Survey copied to production env.");
|
||||
} else if (otherEnvironment.type === "development") {
|
||||
toast.success("Survey copied to development env.");
|
||||
}
|
||||
router.replace(`/environments/${otherEnvironment.id}`);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to copy to ${otherEnvironment.type}`);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="opacity-0.2 absolute left-0 top-0 h-full w-full bg-gray-100">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
|
||||
<div>
|
||||
<span className="sr-only">Open options</span>
|
||||
<EllipsisHorizontalIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-40">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className="flex w-full items-center"
|
||||
href={`/environments/${environmentId}/surveys/${survey.id}/edit`}>
|
||||
<PencilSquareIcon className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={async () => {
|
||||
duplicateSurveyAndRefresh(survey.id);
|
||||
}}>
|
||||
<DocumentDuplicateIcon className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
{environment.type === "development" ? (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
copyToOtherEnvironment(survey.id);
|
||||
}}>
|
||||
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
|
||||
Copy to Prod
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
) : environment.type === "production" ? (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
copyToOtherEnvironment(survey.id);
|
||||
}}>
|
||||
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
|
||||
Copy to Dev
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{survey.type === "link" && survey.status !== "draft" && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className="flex w-full items-center"
|
||||
href={`/s/${survey.id}?preview=true`}
|
||||
target="_blank">
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
Preview Survey
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.protocol}//${window.location.host}/s/${survey.id}`
|
||||
);
|
||||
toast.success("Copied link to clipboard");
|
||||
}}>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
Copy Link
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
}}>
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DeleteDialog
|
||||
deleteWhat="Survey"
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
onDelete={() => handleDeleteSurvey(survey)}
|
||||
text="Are you sure you want to delete this survey and all of its responses? This action cannot be undone."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { Template } from "@/../../packages/types/templates";
|
||||
import DeleteDialog from "@/components/shared/DeleteDialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/shared/DropdownMenu";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
|
||||
import { useEnvironment } from "@/lib/environments/environments";
|
||||
import { createSurvey, deleteSurvey, duplicateSurvey, useSurveys } from "@/lib/surveys/surveys";
|
||||
import { Badge, ErrorComponent } from "@formbricks/ui";
|
||||
import {
|
||||
ComputerDesktopIcon,
|
||||
DocumentDuplicateIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
LinkIcon,
|
||||
PencilSquareIcon,
|
||||
EyeIcon,
|
||||
TrashIcon,
|
||||
PlusIcon,
|
||||
ArrowUpOnSquareStackIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { Badge } from "@formbricks/ui";
|
||||
import { ComputerDesktopIcon, LinkIcon, PlusIcon } from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import TemplateList from "./templates/TemplateList";
|
||||
import { useEffect } from "react";
|
||||
import { changeEnvironment } from "@/lib/environments/changeEnvironments";
|
||||
import { TProduct } from "@formbricks/types/v1/product";
|
||||
import SurveyDropDownMenu from "@/app/(app)/environments/[environmentId]/surveys/SurveyDropDownMenu";
|
||||
import SurveyStarter from "@/app/(app)/environments/[environmentId]/surveys/SurveyStarter";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
|
||||
import { getEnvironment, getEnvironments } from "@formbricks/lib/services/environment";
|
||||
import { getSurveysWithAnalytics } from "@formbricks/lib/services/survey";
|
||||
import type { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
|
||||
import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
|
||||
interface SurveyListProps {
|
||||
environmentId: string;
|
||||
product: TProduct;
|
||||
}
|
||||
|
||||
export default function SurveysList({ environmentId, product }: SurveyListProps) {
|
||||
const router = useRouter();
|
||||
const { surveys, mutateSurveys, isLoadingSurveys, isErrorSurveys } = useSurveys(environmentId);
|
||||
const { environment, isErrorEnvironment, isLoadingEnvironment } = useEnvironment(environmentId);
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isCreateSurveyLoading, setIsCreateSurveyLoading] = useState(false);
|
||||
|
||||
const [activeSurvey, setActiveSurvey] = useState("" as any);
|
||||
const [activeSurveyIdx, setActiveSurveyIdx] = useState("" as any);
|
||||
const [otherEnvironment, setOtherEnvironment] = useState("" as any);
|
||||
|
||||
useEffect(() => {
|
||||
if (environment) {
|
||||
setOtherEnvironment(environment.product.environments.find((e) => e.type !== environment.type));
|
||||
}
|
||||
}, [environment]);
|
||||
|
||||
const newSurvey = async () => {
|
||||
router.push(`/environments/${environmentId}/surveys/templates`);
|
||||
};
|
||||
|
||||
const newSurveyFromTemplate = async (template: Template) => {
|
||||
setIsCreateSurveyLoading(true);
|
||||
const augmentedTemplate = {
|
||||
...template.preset,
|
||||
type: environment?.widgetSetupCompleted ? "web" : "link",
|
||||
};
|
||||
try {
|
||||
const survey = await createSurvey(environmentId, augmentedTemplate);
|
||||
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
|
||||
} catch (e) {
|
||||
toast.error("An error occured creating a new survey");
|
||||
setIsCreateSurveyLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSurveyAction = async (survey, surveyIdx) => {
|
||||
try {
|
||||
await deleteSurvey(environmentId, survey.id);
|
||||
// remove locally
|
||||
const updatedsurveys = JSON.parse(JSON.stringify(surveys));
|
||||
updatedsurveys.splice(surveyIdx, 1);
|
||||
mutateSurveys(updatedsurveys);
|
||||
setDeleteDialogOpen(false);
|
||||
toast.success("Survey deleted successfully.");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const duplicateSurveyAndRefresh = async (surveyId) => {
|
||||
try {
|
||||
await duplicateSurvey(environmentId, surveyId);
|
||||
mutateSurveys();
|
||||
toast.success("Survey duplicated successfully.");
|
||||
} catch (error) {
|
||||
toast.error("Failed to duplicate the survey.");
|
||||
}
|
||||
};
|
||||
|
||||
const copyToOtherEnvironment = async (surveyId) => {
|
||||
try {
|
||||
await duplicateSurvey(environmentId, surveyId, otherEnvironment.id);
|
||||
if (otherEnvironment.type === "production") {
|
||||
toast.success("Survey copied to production env.");
|
||||
} else if (otherEnvironment.type === "development") {
|
||||
toast.success("Survey copied to development env.");
|
||||
}
|
||||
changeEnvironment(otherEnvironment.type, environment, router);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to copy to ${otherEnvironment.type}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingSurveys || isLoadingEnvironment) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (isErrorSurveys || isErrorEnvironment) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
export default async function SurveysList({ environmentId }: { environmentId: string }) {
|
||||
const product = await getProductByEnvironmentId(environmentId);
|
||||
const environment = await getEnvironment(environmentId);
|
||||
const surveys: TSurveyWithAnalytics[] = await getSurveysWithAnalytics(environmentId);
|
||||
const environments: TEnvironment[] = await getEnvironments(product.id);
|
||||
const otherEnvironment = environments.find((e) => e.type !== environment.type);
|
||||
|
||||
if (surveys.length === 0) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col py-12">
|
||||
{isCreateSurveyLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<>
|
||||
<div className="px-7 pb-4">
|
||||
<h1 className="text-3xl font-extrabold text-slate-700">
|
||||
You're all set! Time to create your first survey.
|
||||
</h1>
|
||||
</div>
|
||||
<TemplateList
|
||||
environmentId={environmentId}
|
||||
onTemplateClick={(template) => {
|
||||
newSurveyFromTemplate(template);
|
||||
}}
|
||||
environment={environment}
|
||||
product={product}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return <SurveyStarter environmentId={environmentId} environment={environment} product={product} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="grid grid-cols-2 place-content-stretch gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-5 ">
|
||||
<button onClick={() => newSurvey()}>
|
||||
<Link href={`/environments/${environmentId}/surveys/templates`}>
|
||||
<li className="col-span-1 h-56">
|
||||
<div className="delay-50 flex h-full items-center justify-center overflow-hidden rounded-md bg-gradient-to-br from-slate-900 to-slate-800 font-light text-white shadow transition ease-in-out hover:scale-105 hover:from-slate-800 hover:to-slate-700">
|
||||
<div id="main-cta" className="px-4 py-8 sm:p-14 xl:p-10">
|
||||
@@ -159,10 +33,10 @@ export default function SurveysList({ environmentId, product }: SurveyListProps)
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</button>
|
||||
</Link>
|
||||
{surveys
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
.map((survey, surveyIdx) => (
|
||||
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
|
||||
.map((survey) => (
|
||||
<li key={survey.id} className="relative col-span-1 h-56">
|
||||
<div className="delay-50 flex h-full flex-col justify-between rounded-md bg-white shadow transition ease-in-out hover:scale-105">
|
||||
<div className="px-6 py-4">
|
||||
@@ -198,119 +72,28 @@ export default function SurveysList({ environmentId, product }: SurveyListProps)
|
||||
tooltip
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
<p className="ml-2 text-xs text-slate-400 ">{survey._count?.responses} responses</p>
|
||||
<p className="ml-2 text-xs text-slate-400 ">
|
||||
{survey.analytics.numResponses} responses
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{survey.status === "draft" && (
|
||||
<span className="text-xs italic text-slate-400">Draft</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
|
||||
<div>
|
||||
<span className="sr-only">Open options</span>
|
||||
<EllipsisHorizontalIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-40">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className="flex w-full items-center"
|
||||
href={`/environments/${environmentId}/surveys/${survey.id}/edit`}>
|
||||
<PencilSquareIcon className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={async () => {
|
||||
duplicateSurveyAndRefresh(survey.id);
|
||||
}}>
|
||||
<DocumentDuplicateIcon className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
{environment.type === "development" ? (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
copyToOtherEnvironment(survey.id);
|
||||
}}>
|
||||
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
|
||||
Copy to Prod
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
) : environment.type === "production" ? (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
copyToOtherEnvironment(survey.id);
|
||||
}}>
|
||||
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
|
||||
Copy to Dev
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{survey.type === "link" && survey.status !== "draft" && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className="flex w-full items-center"
|
||||
href={`${window.location.protocol}//${window.location.host}/s/${survey.id}?preview=true`}
|
||||
target="_blank">
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
Preview Survey
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.protocol}//${window.location.host}/s/${survey.id}`
|
||||
);
|
||||
toast.success("Copied link to clipboard");
|
||||
}}>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
Copy Link
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
setActiveSurvey(survey);
|
||||
setActiveSurveyIdx(surveyIdx);
|
||||
setDeleteDialogOpen(true);
|
||||
}}>
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<SurveyDropDownMenu
|
||||
survey={survey}
|
||||
key={`survey-${survey.id}`}
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
otherEnvironment={otherEnvironment}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<DeleteDialog
|
||||
deleteWhat="Survey"
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
onDelete={() => deleteSurveyAction(activeSurvey, activeSurveyIdx)}
|
||||
text="Are you sure you want to delete this survey and all of its responses? This action cannot be undone."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
import { Template } from "@/../../packages/types/templates";
|
||||
import { createSurveyAction } from "./actions";
|
||||
import TemplateList from "@/app/(app)/environments/[environmentId]/surveys/templates/TemplateList";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import type { TProduct } from "@formbricks/types/v1/product";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
export default function SurveyStarter({
|
||||
environmentId,
|
||||
environment,
|
||||
product,
|
||||
}: {
|
||||
environmentId: string;
|
||||
environment: TEnvironment;
|
||||
product: TProduct;
|
||||
}) {
|
||||
const [isCreateSurveyLoading, setIsCreateSurveyLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const newSurveyFromTemplate = async (template: Template) => {
|
||||
setIsCreateSurveyLoading(true);
|
||||
const augmentedTemplate = {
|
||||
...template.preset,
|
||||
type: environment?.widgetSetupCompleted ? "web" : "link",
|
||||
};
|
||||
try {
|
||||
const survey = await createSurveyAction(environmentId, augmentedTemplate);
|
||||
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
|
||||
} catch (e) {
|
||||
toast.error("An error occured creating a new survey");
|
||||
setIsCreateSurveyLoading(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col py-12">
|
||||
{isCreateSurveyLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<>
|
||||
<div className="px-7 pb-4">
|
||||
<h1 className="text-3xl font-extrabold text-slate-700">
|
||||
You're all set! Time to create your first survey.
|
||||
</h1>
|
||||
</div>
|
||||
<TemplateList
|
||||
environmentId={environmentId}
|
||||
onTemplateClick={(template) => {
|
||||
newSurveyFromTemplate(template);
|
||||
}}
|
||||
environment={environment}
|
||||
product={product}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
import { IS_FORMBRICKS_CLOUD, RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
|
||||
import { getSurveyResponses } from "@formbricks/lib/services/response";
|
||||
import { getSurvey } from "@formbricks/lib/services/survey";
|
||||
import { getSurveyWithAnalytics } from "@formbricks/lib/services/survey";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/services/team";
|
||||
|
||||
export const getAnalysisData = async (surveyId: string, environmentId: string) => {
|
||||
const [survey, team, allResponses] = await Promise.all([
|
||||
getSurvey(surveyId),
|
||||
getSurveyWithAnalytics(surveyId),
|
||||
getTeamByEnvironmentId(environmentId),
|
||||
getSurveyResponses(surveyId),
|
||||
]);
|
||||
|
||||
-7
@@ -30,13 +30,6 @@ export interface OpenTextSummaryProps {
|
||||
scale?: "number" | "star" | "smiley";
|
||||
range?: number;
|
||||
}[];
|
||||
meta?: {
|
||||
userAgent?: {
|
||||
browser?: string;
|
||||
os?: string;
|
||||
device?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { createSurvey } from "@formbricks/lib/services/survey";
|
||||
|
||||
export async function createSurveyAction(environmentId: string, surveyBody: any) {
|
||||
return await createSurvey(environmentId, surveyBody);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
|
||||
export default function LoadingPage() {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -2,19 +2,13 @@ import ContentWrapper from "@/components/shared/ContentWrapper";
|
||||
import WidgetStatusIndicator from "@/components/shared/WidgetStatusIndicator";
|
||||
import SurveysList from "./SurveyList";
|
||||
import { Metadata } from "next";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Your Surveys",
|
||||
};
|
||||
|
||||
export default async function SurveysPage({ params }) {
|
||||
const environmentId = params.environmentId;
|
||||
const product = await getProductByEnvironmentId(environmentId);
|
||||
|
||||
return (
|
||||
<ContentWrapper className="flex h-full flex-col justify-between">
|
||||
<SurveysList environmentId={params.environmentId} product={product} />
|
||||
<SurveysList environmentId={params.environmentId} />
|
||||
<WidgetStatusIndicator environmentId={params.environmentId} type="mini" />
|
||||
</ContentWrapper>
|
||||
);
|
||||
|
||||
+18
-12
@@ -1,21 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { createSurvey } from "@/lib/surveys/surveys";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import { useProfile } from "@/lib/profile";
|
||||
import { replacePresetPlaceholders } from "@/lib/templates";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import type { Template } from "@formbricks/types/templates";
|
||||
import { Button, ErrorComponent } from "@formbricks/ui";
|
||||
import { PlusCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { SparklesIcon } from "@heroicons/react/24/solid";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { customSurvey, templates } from "./templates";
|
||||
import { SplitIcon } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
|
||||
import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import type { TProduct } from "@formbricks/types/v1/product";
|
||||
import { useProfile } from "@/lib/profile";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import {
|
||||
Button,
|
||||
ErrorComponent,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@formbricks/ui";
|
||||
import { PlusCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { SparklesIcon } from "@heroicons/react/24/solid";
|
||||
import { SplitIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createSurveyAction } from "./actions";
|
||||
import { customSurvey, templates } from "./templates";
|
||||
|
||||
type TemplateList = {
|
||||
environmentId: string;
|
||||
@@ -59,7 +65,7 @@ export default function TemplateList({ environmentId, onTemplateClick, product,
|
||||
...activeTemplate.preset,
|
||||
type: environment?.widgetSetupCompleted ? "web" : "link",
|
||||
};
|
||||
const survey = await createSurvey(environmentId, augmentedTemplate);
|
||||
const survey = await createSurveyAction(environmentId, augmentedTemplate);
|
||||
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { createSurvey } from "@formbricks/lib/services/survey";
|
||||
|
||||
export async function createSurveyAction(environmentId: string, surveyBody: any) {
|
||||
return await createSurvey(environmentId, surveyBody);
|
||||
}
|
||||
@@ -240,7 +240,7 @@ export const authOptions: NextAuthOptions = {
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return "/auth/error?error=email-conflict";
|
||||
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
|
||||
@@ -251,7 +251,7 @@ export const authOptions: NextAuthOptions = {
|
||||
});
|
||||
|
||||
if (existingUserWithEmail) {
|
||||
return "/auth/error?error=use-email-login";
|
||||
return "/auth/login?error=A%20user%20with%20this%20email%20exists%20already.";
|
||||
}
|
||||
|
||||
await prisma.user.create({
|
||||
|
||||
@@ -63,7 +63,7 @@ const notificationInsight = (insights: Insights) =>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<p style="font-size:0.9em">Completion %</p>
|
||||
<h1>${insights.completionRate.toFixed(2)}%</h1>
|
||||
<h1>${insights.totalDisplays === 0 ? "N/A" : `${Math.round(insights.completionRate)}%`}</h1>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -91,30 +91,34 @@ const notificationLiveSurveys = (surveys: Survey[], environmentId: string) => {
|
||||
if (!surveys.length) return ` `;
|
||||
|
||||
return surveys
|
||||
.filter((survey) => survey.responses.length > 0)
|
||||
.map((survey) => {
|
||||
const displayStatus = convertSurveyStatus(survey.status);
|
||||
const isLive = displayStatus === "Live";
|
||||
const noResponseLastWeek = isLive && survey.responses.length === 0;
|
||||
|
||||
return `
|
||||
<div style="display: block; margin-top:3em;">
|
||||
<a href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
|
||||
survey.id
|
||||
}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA" style="color:#1e293b;">
|
||||
survey.id
|
||||
}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA" style="color:#1e293b;">
|
||||
<h2 style="text-decoration: underline; display:inline;">${survey.name}</h2>
|
||||
</a>
|
||||
<span style="display: inline; margin-left: 10px; background-color: ${
|
||||
isLive ? "#34D399" : "#a7f3d0"
|
||||
isLive ? "#34D399" : "#cbd5e1"
|
||||
}; color: ${isLive ? "#F3F4F6" : "#15803d"}; border-radius:99px; padding: 2px 8px; font-size:0.9em">
|
||||
${displayStatus}
|
||||
</span>
|
||||
${createSurveyFields(survey.responses)}
|
||||
${
|
||||
survey.responsesCount >= 1
|
||||
noResponseLastWeek
|
||||
? "<p>No new response received this week 🕵️</p>"
|
||||
: createSurveyFields(survey.responses)
|
||||
}
|
||||
${
|
||||
survey.responsesCount >= 0
|
||||
? `<a class="button" href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
|
||||
survey.id
|
||||
}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA">
|
||||
${getButtonLabel(survey.responsesCount)}
|
||||
${noResponseLastWeek ? "View previous responses" : getButtonLabel(survey.responsesCount)}
|
||||
</a>`
|
||||
: ""
|
||||
}
|
||||
@@ -154,8 +158,7 @@ const notificationFooter = () => {
|
||||
return `
|
||||
<p style="margin-bottom:0px; padding-top:1em; font-weight:500">All the best,</p>
|
||||
<p style="margin-top:0px;">The Formbricks Team 🤍</p>
|
||||
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:8px; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1em;"><p><i>This is a Beta feature. If you experience any issues, please let us know by replying to this email 🙏</i></p></div>
|
||||
<p style="margin-top:0.8em; text-align:center; font-size:0.8em; line-height:1em;">The Formbricks Team 🤍</p>
|
||||
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:8px; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1.2em;"><p><i>This is a Beta feature. If you experience any issues, please let us know by replying to this email 🙏</i></p></div>
|
||||
`;
|
||||
};
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ const getNotificationResponse = (environment: EnvironmentData, productName: stri
|
||||
insights.totalCompletedResponses += survey.responses.filter((r) => r.finished).length;
|
||||
insights.totalDisplays += survey.displays.length;
|
||||
insights.totalResponses += survey.responses.length;
|
||||
insights.completionRate = Math.round((insights.totalCompletedResponses / insights.totalDisplays) * 100);
|
||||
insights.completionRate = Math.round((insights.totalCompletedResponses / insights.totalResponses) * 100);
|
||||
}
|
||||
// build the notification response needed for the emails
|
||||
const lastWeekDate = new Date();
|
||||
@@ -160,6 +160,11 @@ const getProducts = async (): Promise<ProductData[]> => {
|
||||
},
|
||||
},
|
||||
displays: {
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: sevenDaysAgo,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
status: true,
|
||||
},
|
||||
|
||||
@@ -1,56 +1,43 @@
|
||||
import { INTERNAL_SECRET } from "@formbricks/lib/constants";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { NextResponse } from "next/server";
|
||||
import { AttributeClass } from "@prisma/client";
|
||||
import { responses } from "@/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/lib/api/validator";
|
||||
import { sendResponseFinishedEmail } from "@/lib/email";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { INTERNAL_SECRET } from "@formbricks/lib/constants";
|
||||
import { convertDatesInObject } from "@formbricks/lib/time";
|
||||
import { Question } from "@formbricks/types/questions";
|
||||
import { NotificationSettings } from "@formbricks/types/users";
|
||||
import { ZPipelineInput } from "@formbricks/types/v1/pipelines";
|
||||
import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { internalSecret, environmentId, surveyId, event, data } = await request.json();
|
||||
if (!internalSecret) {
|
||||
console.error("Pipeline: Missing internalSecret");
|
||||
return new Response("Missing internalSecret", {
|
||||
status: 400,
|
||||
});
|
||||
// check authentication with x-api-key header and CRON_SECRET env variable
|
||||
if (headers().get("x-api-key") !== INTERNAL_SECRET) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
if (!environmentId) {
|
||||
console.error("Pipeline: Missing environmentId");
|
||||
return new Response("Missing environmentId", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (!surveyId) {
|
||||
console.error("Pipeline: Missing surveyId");
|
||||
return new Response("Missing surveyId", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (!event) {
|
||||
console.error("Pipeline: Missing event");
|
||||
return new Response("Missing event", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (!data) {
|
||||
console.error("Pipeline: Missing data");
|
||||
return new Response("Missing data", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (internalSecret !== INTERNAL_SECRET) {
|
||||
console.error("Pipeline: internalSecret doesn't match");
|
||||
return new Response("Invalid internalSecret", {
|
||||
status: 401,
|
||||
});
|
||||
const jsonInput = await request.json();
|
||||
|
||||
convertDatesInObject(jsonInput);
|
||||
|
||||
const inputValidation = ZPipelineInput.safeParse(jsonInput);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
console.error(inputValidation.error);
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const { environmentId, surveyId, event, response } = inputValidation.data;
|
||||
|
||||
// get all webhooks of this environment where event in triggers
|
||||
const webhooks = await prisma.webhook.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
triggers: {
|
||||
hasSome: event,
|
||||
has: event,
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
@@ -75,7 +62,7 @@ export async function POST(request: Request) {
|
||||
body: JSON.stringify({
|
||||
webhookId: webhook.id,
|
||||
event,
|
||||
data,
|
||||
data: response,
|
||||
}),
|
||||
});
|
||||
})
|
||||
@@ -136,32 +123,10 @@ export async function POST(request: Request) {
|
||||
name: surveyData.name,
|
||||
questions: JSON.parse(JSON.stringify(surveyData.questions)) as Question[],
|
||||
};
|
||||
// get person for response
|
||||
let person: {
|
||||
id: string;
|
||||
attributes: { id: string; value: string; attributeClass: AttributeClass }[];
|
||||
} | null;
|
||||
if (data.personId) {
|
||||
person = await prisma.person.findUnique({
|
||||
where: {
|
||||
id: data.personId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
attributeClass: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
// send email to all users
|
||||
await Promise.all(
|
||||
usersWithNotifications.map(async (user) => {
|
||||
await sendResponseFinishedEmail(user.email, environmentId, survey, data, person);
|
||||
await sendResponseFinishedEmail(user.email, environmentId, survey, response);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,11 +68,7 @@ export async function PUT(
|
||||
event: "responseUpdated",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
// only send the updated fields
|
||||
data: {
|
||||
...response,
|
||||
data: inputValidation.data.data,
|
||||
},
|
||||
response,
|
||||
});
|
||||
|
||||
if (response.finished) {
|
||||
@@ -82,7 +78,7 @@ export async function PUT(
|
||||
event: "responseFinished",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
data: response,
|
||||
response: response,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
event: "responseCreated",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: response.surveyId,
|
||||
data: response,
|
||||
response: response,
|
||||
});
|
||||
|
||||
if (responseInput.finished) {
|
||||
@@ -76,7 +76,7 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
event: "responseFinished",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: response.surveyId,
|
||||
data: response,
|
||||
response: response,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { select } from "@formbricks/lib/services/survey";
|
||||
import { selectSurvey } from "@formbricks/lib/services/survey";
|
||||
import { TPerson } from "@formbricks/types/v1/people";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
|
||||
@@ -48,7 +48,7 @@ export const getSurveys = async (environmentId: string, person: TPerson): Promis
|
||||
],
|
||||
},
|
||||
select: {
|
||||
...select,
|
||||
...selectSurvey,
|
||||
attributeFilters: {
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function GET() {
|
||||
if (error instanceof DatabaseError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
}
|
||||
throw error;
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
|
||||
import { useEnvironment } from "@/lib/environments/environments";
|
||||
import { ArchiveBoxIcon, CheckIcon, PauseIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
@@ -2,8 +2,7 @@ import { env } from "@/env.mjs";
|
||||
import { getQuestionResponseMapping } from "@/lib/responses/questionResponseMapping";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { Question } from "@formbricks/types/questions";
|
||||
import { Response } from "@formbricks/types/responses";
|
||||
import { AttributeClass } from "@prisma/client";
|
||||
import { TResponse } from "@formbricks/types/v1/responses";
|
||||
import { withEmailTemplate } from "./email-template";
|
||||
import { createInviteToken, createToken } from "./jwt";
|
||||
|
||||
@@ -120,16 +119,15 @@ export const sendResponseFinishedEmail = async (
|
||||
email: string,
|
||||
environmentId: string,
|
||||
survey: { id: string; name: string; questions: Question[] },
|
||||
response: Response,
|
||||
person: { id: string; attributes: { id: string; value: string; attributeClass: AttributeClass }[] } | null
|
||||
response: TResponse
|
||||
) => {
|
||||
const personEmail = person?.attributes?.find((a) => a.attributeClass?.name === "email")?.value;
|
||||
const personEmail = response.person?.attributes["email"];
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: personEmail
|
||||
? `${personEmail} just completed your ${survey.name} survey ✅`
|
||||
: `A response for ${survey.name} was completed ✅`,
|
||||
replyTo: personEmail || env.MAIL_FROM,
|
||||
replyTo: personEmail?.toString() || env.MAIL_FROM,
|
||||
html: withEmailTemplate(`<h1>Hey 👋</h1>Someone just completed your survey <strong>${
|
||||
survey.name
|
||||
}</strong><br/>
|
||||
|
||||
@@ -1,28 +1,18 @@
|
||||
import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { TPipelineTrigger } from "@formbricks/types/v1/pipelines";
|
||||
import { TPipelineInput } from "@formbricks/types/v1/pipelines";
|
||||
|
||||
export async function sendToPipeline({
|
||||
event,
|
||||
surveyId,
|
||||
environmentId,
|
||||
data,
|
||||
}: {
|
||||
event: TPipelineTrigger;
|
||||
surveyId: string;
|
||||
environmentId: string;
|
||||
data: any;
|
||||
}) {
|
||||
export async function sendToPipeline({ event, surveyId, environmentId, response }: TPipelineInput) {
|
||||
return fetch(`${WEBAPP_URL}/api/pipeline`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": INTERNAL_SECRET,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
internalSecret: INTERNAL_SECRET,
|
||||
environmentId: environmentId,
|
||||
surveyId: surveyId,
|
||||
event,
|
||||
data,
|
||||
response,
|
||||
}),
|
||||
}).catch((error) => {
|
||||
console.error(`Error sending event to pipeline: ${error}`);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Question } from "@formbricks/types/questions";
|
||||
import { Response } from "@formbricks/types/responses";
|
||||
import { TResponse } from "@formbricks/types/v1/responses";
|
||||
|
||||
export const getQuestionResponseMapping = (
|
||||
survey: { questions: Question[] },
|
||||
response: Response
|
||||
response: TResponse
|
||||
): { question: string; answer: string }[] => {
|
||||
const questionResponseMapping: { question: string; answer: string }[] = [];
|
||||
|
||||
@@ -12,7 +12,7 @@ export const getQuestionResponseMapping = (
|
||||
|
||||
questionResponseMapping.push({
|
||||
question: question.headline,
|
||||
answer,
|
||||
answer: answer.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+91
-19
@@ -1,5 +1,10 @@
|
||||
import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { sendToPipeline } from "@/lib/pipelines";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { TPerson } from "@formbricks/types/v1/people";
|
||||
import { TPipelineInput } from "@formbricks/types/v1/pipelines";
|
||||
import { TResponse } from "@formbricks/types/v1/responses";
|
||||
import { TTag } from "@formbricks/types/v1/tags";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
@@ -42,16 +47,90 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
...response.data,
|
||||
};
|
||||
|
||||
// update response
|
||||
const responseData = await prisma.response.update({
|
||||
const responsePrisma = await prisma.response.update({
|
||||
where: {
|
||||
id: responseId,
|
||||
},
|
||||
data: {
|
||||
...{ ...response, data: newResponseData },
|
||||
...response,
|
||||
data: newResponseData,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
surveyId: true,
|
||||
finished: true,
|
||||
data: true,
|
||||
meta: true,
|
||||
personAttributes: true,
|
||||
person: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
attributes: {
|
||||
select: {
|
||||
value: true,
|
||||
attributeClass: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
text: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
select: {
|
||||
tag: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
environmentId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const transformPrismaPerson = (person): TPerson => {
|
||||
const attributes = person.attributes.reduce((acc, attr) => {
|
||||
acc[attr.attributeClass.name] = attr.value;
|
||||
return acc;
|
||||
}, {} as Record<string, string | number>);
|
||||
|
||||
return {
|
||||
id: person.id,
|
||||
attributes: attributes,
|
||||
createdAt: person.createdAt,
|
||||
updatedAt: person.updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
const responseData: TResponse = {
|
||||
...responsePrisma,
|
||||
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
// send response update to pipeline
|
||||
// don't await to not block the response
|
||||
fetch(`${WEBAPP_URL}/api/pipeline`, {
|
||||
@@ -64,29 +143,22 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
environmentId,
|
||||
surveyId: responseData.surveyId,
|
||||
event: "responseUpdated",
|
||||
data: { id: responseId, ...response },
|
||||
}),
|
||||
response: responseData,
|
||||
} as TPipelineInput),
|
||||
});
|
||||
|
||||
if (response.finished) {
|
||||
// send response to pipeline
|
||||
// don't await to not block the response
|
||||
fetch(`${WEBAPP_URL}/api/pipeline`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
internalSecret: INTERNAL_SECRET,
|
||||
environmentId,
|
||||
surveyId: responseData.surveyId,
|
||||
event: "responseFinished",
|
||||
data: responseData,
|
||||
}),
|
||||
sendToPipeline({
|
||||
environmentId,
|
||||
surveyId: responseData.surveyId,
|
||||
event: "responseFinished",
|
||||
response: responseData,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json(responseData);
|
||||
return res.json({ message: "Response updated" });
|
||||
}
|
||||
|
||||
// Unknown HTTP Method
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { sendToPipeline } from "@/lib/pipelines";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { captureTelemetry } from "@formbricks/lib/telemetry";
|
||||
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
|
||||
import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { captureTelemetry } from "@formbricks/lib/telemetry";
|
||||
import { TPerson } from "@formbricks/types/v1/people";
|
||||
import { TResponse } from "@formbricks/types/v1/responses";
|
||||
import { TTag } from "@formbricks/types/v1/tags";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
const environmentId = req.query.environmentId?.toString();
|
||||
@@ -13,7 +16,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
// CORS
|
||||
if (req.method === "OPTIONS") {
|
||||
res.status(200).end();
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
// POST
|
||||
@@ -75,19 +78,17 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
// find team owner
|
||||
const teamOwnerId = environment.product.team.memberships.find((m) => m.role === "owner")?.userId;
|
||||
|
||||
const createBody = {
|
||||
data: {
|
||||
survey: {
|
||||
connect: {
|
||||
id: surveyId,
|
||||
},
|
||||
const responseInput = {
|
||||
survey: {
|
||||
connect: {
|
||||
id: surveyId,
|
||||
},
|
||||
...response,
|
||||
},
|
||||
...response,
|
||||
};
|
||||
|
||||
if (personId) {
|
||||
createBody.data.person = {
|
||||
responseInput.data.person = {
|
||||
connect: {
|
||||
id: personId,
|
||||
},
|
||||
@@ -95,39 +96,103 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
}
|
||||
|
||||
// create new response
|
||||
const responseData = await prisma.response.create(createBody);
|
||||
const responsePrisma = await prisma.response.create({
|
||||
data: {
|
||||
...responseInput,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
surveyId: true,
|
||||
finished: true,
|
||||
data: true,
|
||||
meta: true,
|
||||
personAttributes: true,
|
||||
person: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
attributes: {
|
||||
select: {
|
||||
value: true,
|
||||
attributeClass: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
text: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
select: {
|
||||
tag: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
environmentId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const transformPrismaPerson = (person): TPerson => {
|
||||
const attributes = person.attributes.reduce((acc, attr) => {
|
||||
acc[attr.attributeClass.name] = attr.value;
|
||||
return acc;
|
||||
}, {} as Record<string, string | number>);
|
||||
|
||||
return {
|
||||
id: person.id,
|
||||
attributes: attributes,
|
||||
createdAt: person.createdAt,
|
||||
updatedAt: person.updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
const responseData: TResponse = {
|
||||
...responsePrisma,
|
||||
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
// send response to pipeline
|
||||
// don't await to not block the response
|
||||
fetch(`${WEBAPP_URL}/api/pipeline`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
internalSecret: INTERNAL_SECRET,
|
||||
environmentId,
|
||||
surveyId,
|
||||
event: "responseCreated",
|
||||
data: responseData,
|
||||
}),
|
||||
sendToPipeline({
|
||||
environmentId,
|
||||
surveyId,
|
||||
event: "responseCreated",
|
||||
response: responseData,
|
||||
});
|
||||
|
||||
if (response.finished) {
|
||||
// send response to pipeline
|
||||
// don't await to not block the response
|
||||
fetch(`${WEBAPP_URL}/api/pipeline`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
internalSecret: INTERNAL_SECRET,
|
||||
environmentId,
|
||||
surveyId,
|
||||
event: "responseFinished",
|
||||
data: responseData,
|
||||
}),
|
||||
sendToPipeline({
|
||||
environmentId,
|
||||
surveyId,
|
||||
event: "responseFinished",
|
||||
response: responseData,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TActionClassNoCodeConfig } from "@formbricks/types/v1/actionClasses";
|
||||
import { TResponsePersonAttributes, TResponseData } from "@formbricks/types/v1/responses";
|
||||
import { TResponsePersonAttributes, TResponseData, TResponseMeta } from "@formbricks/types/v1/responses";
|
||||
import { TSurveyClosedMessage, TSurveyQuestions, TSurveyThankYouCard } from "@formbricks/types/v1/surveys";
|
||||
import { TUserNotificationSettings } from "@formbricks/types/v1/users";
|
||||
|
||||
@@ -8,7 +8,7 @@ declare global {
|
||||
export type EventProperties = { [key: string]: string };
|
||||
export type EventClassNoCodeConfig = TActionClassNoCodeConfig;
|
||||
export type ResponseData = TResponseData;
|
||||
export type ResponseMeta = { [key: string]: string };
|
||||
export type ResponseMeta = TResponseMeta;
|
||||
export type ResponsePersonAttributes = TResponsePersonAttributes;
|
||||
export type SurveyQuestions = TSurveyQuestions;
|
||||
export type SurveyThankYouCard = TSurveyThankYouCard;
|
||||
|
||||
@@ -268,7 +268,7 @@ model EventClass {
|
||||
description String?
|
||||
type EventType
|
||||
events Event[]
|
||||
/// @zod.custom(imports.ZEventClassNoCodeConfig)
|
||||
/// @zod.custom(imports.ZActionClassNoCodeConfig)
|
||||
/// [EventClassNoCodeConfig]
|
||||
noCodeConfig Json?
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import z from "zod";
|
||||
|
||||
export const ZEventProperties = z.record(z.string());
|
||||
export { ZEventClassNoCodeConfig } from "@formbricks/types/v1/eventClasses";
|
||||
export { ZActionClassNoCodeConfig } from "@formbricks/types/v1/actionClasses";
|
||||
|
||||
export { ZResponseData, ZResponsePersonAttributes } from "@formbricks/types/v1/responses";
|
||||
export const ZResponseMeta = z.record(z.union([z.string(), z.number()]));
|
||||
export { ZResponseData, ZResponsePersonAttributes, ZResponseMeta } from "@formbricks/types/v1/responses";
|
||||
|
||||
export { ZSurveyQuestions, ZSurveyThankYouCard, ZSurveyClosedMessage } from "@formbricks/types/v1/surveys";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Matthias Nannt, Johannes Dancker
|
||||
Copyright (c) 2023 Formbricks GmbH
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Matthias Nannt, Johannes Dancker
|
||||
Copyright (c) 2023 Formbricks GmbH
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { constants } from "../constants";
|
||||
|
||||
const {
|
||||
environmentId,
|
||||
apiHost,
|
||||
sessionId,
|
||||
expiryTime,
|
||||
surveyId,
|
||||
@@ -26,69 +25,71 @@ const {
|
||||
export const mockInitResponse = () => {
|
||||
fetchMock.mockResponseOnce(
|
||||
JSON.stringify({
|
||||
person: {
|
||||
id: initialPersonUid,
|
||||
createdAt: "2021-03-09T15:00:00.000Z",
|
||||
updatedAt: "2021-03-09T15:00:00.000Z",
|
||||
attributes: {},
|
||||
},
|
||||
session: {
|
||||
id: sessionId,
|
||||
createdAt: "2021-03-09T15:00:00.000Z",
|
||||
updatedAt: "2021-03-09T15:00:00.000Z",
|
||||
expiresAt: expiryTime,
|
||||
},
|
||||
surveys: [
|
||||
{
|
||||
id: surveyId,
|
||||
questions: [
|
||||
{
|
||||
id: questionOneId,
|
||||
type: "multipleChoiceSingle",
|
||||
choices: [
|
||||
{
|
||||
id: choiceOneId,
|
||||
label: "Not at all disappointed",
|
||||
},
|
||||
{
|
||||
id: choiceTwoId,
|
||||
label: "Somewhat disappointed",
|
||||
},
|
||||
{
|
||||
id: choiceThreeId,
|
||||
label: "Very disappointed",
|
||||
},
|
||||
],
|
||||
headline: "How disappointed would you be if you could no longer use Test-Formbricks?",
|
||||
required: true,
|
||||
subheader: "Please select one of the following options:",
|
||||
},
|
||||
{
|
||||
id: questionTwoId,
|
||||
type: "openText",
|
||||
headline: "How can we improve Test-Formbricks for you?",
|
||||
required: true,
|
||||
subheader: "Please be as specific as possible.",
|
||||
},
|
||||
],
|
||||
triggers: [],
|
||||
thankYouCard: {
|
||||
enabled: true,
|
||||
headline: "Thank you!",
|
||||
subheader: "We appreciate your feedback.",
|
||||
},
|
||||
autoClose: null,
|
||||
delay: 0,
|
||||
data: {
|
||||
person: {
|
||||
id: initialPersonUid,
|
||||
createdAt: "2021-03-09T15:00:00.000Z",
|
||||
updatedAt: "2021-03-09T15:00:00.000Z",
|
||||
attributes: {},
|
||||
},
|
||||
session: {
|
||||
id: sessionId,
|
||||
createdAt: "2021-03-09T15:00:00.000Z",
|
||||
updatedAt: "2021-03-09T15:00:00.000Z",
|
||||
expiresAt: expiryTime,
|
||||
},
|
||||
surveys: [
|
||||
{
|
||||
id: surveyId,
|
||||
questions: [
|
||||
{
|
||||
id: questionOneId,
|
||||
type: "multipleChoiceSingle",
|
||||
choices: [
|
||||
{
|
||||
id: choiceOneId,
|
||||
label: "Not at all disappointed",
|
||||
},
|
||||
{
|
||||
id: choiceTwoId,
|
||||
label: "Somewhat disappointed",
|
||||
},
|
||||
{
|
||||
id: choiceThreeId,
|
||||
label: "Very disappointed",
|
||||
},
|
||||
],
|
||||
headline: "How disappointed would you be if you could no longer use Test-Formbricks?",
|
||||
required: true,
|
||||
subheader: "Please select one of the following options:",
|
||||
},
|
||||
{
|
||||
id: questionTwoId,
|
||||
type: "openText",
|
||||
headline: "How can we improve Test-Formbricks for you?",
|
||||
required: true,
|
||||
subheader: "Please be as specific as possible.",
|
||||
},
|
||||
],
|
||||
triggers: [],
|
||||
thankYouCard: {
|
||||
enabled: true,
|
||||
headline: "Thank you!",
|
||||
subheader: "We appreciate your feedback.",
|
||||
},
|
||||
autoClose: null,
|
||||
delay: 0,
|
||||
},
|
||||
],
|
||||
noCodeActionClasses: [],
|
||||
product: {
|
||||
noCodeEvents: [],
|
||||
brandColor: "#20b398",
|
||||
formbricksSignature: true,
|
||||
placement: "bottomRight",
|
||||
darkOverlay: false,
|
||||
clickOutsideClose: true,
|
||||
},
|
||||
],
|
||||
noCodeActionClasses: [],
|
||||
product: {
|
||||
noCodeEvents: [],
|
||||
brandColor: "#20b398",
|
||||
formbricksSignature: true,
|
||||
placement: "bottomRight",
|
||||
darkOverlay: false,
|
||||
clickOutsideClose: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -97,18 +98,20 @@ export const mockInitResponse = () => {
|
||||
export const mockSetUserIdResponse = () => {
|
||||
fetchMock.mockResponseOnce(
|
||||
JSON.stringify({
|
||||
surveys: [],
|
||||
session: {
|
||||
id: sessionId,
|
||||
createdAt: "2021-03-09T15:00:00.000Z",
|
||||
updatedAt: "2021-03-09T15:00:00.000Z",
|
||||
expiresAt: expiryTime,
|
||||
},
|
||||
noCodeActionClasses: [],
|
||||
person: {
|
||||
id: initialPersonUid,
|
||||
environmentId,
|
||||
attributes: { userId: initialUserId },
|
||||
data: {
|
||||
surveys: [],
|
||||
session: {
|
||||
id: sessionId,
|
||||
createdAt: "2021-03-09T15:00:00.000Z",
|
||||
updatedAt: "2021-03-09T15:00:00.000Z",
|
||||
expiresAt: expiryTime,
|
||||
},
|
||||
noCodeActionClasses: [],
|
||||
person: {
|
||||
id: initialPersonUid,
|
||||
environmentId,
|
||||
attributes: { userId: initialUserId },
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -117,18 +120,20 @@ export const mockSetUserIdResponse = () => {
|
||||
export const mockSetEmailIdResponse = () => {
|
||||
fetchMock.mockResponseOnce(
|
||||
JSON.stringify({
|
||||
surveys: [],
|
||||
session: {
|
||||
id: sessionId,
|
||||
createdAt: "2021-03-09T15:00:00.000Z",
|
||||
updatedAt: "2021-03-09T15:00:00.000Z",
|
||||
expiresAt: expiryTime,
|
||||
},
|
||||
noCodeActionClasses: [],
|
||||
person: {
|
||||
id: initialPersonUid,
|
||||
environmentId,
|
||||
attributes: { userId: initialUserId, email: initialUserEmail },
|
||||
data: {
|
||||
surveys: [],
|
||||
session: {
|
||||
id: sessionId,
|
||||
createdAt: "2021-03-09T15:00:00.000Z",
|
||||
updatedAt: "2021-03-09T15:00:00.000Z",
|
||||
expiresAt: expiryTime,
|
||||
},
|
||||
noCodeActionClasses: [],
|
||||
person: {
|
||||
id: initialPersonUid,
|
||||
environmentId,
|
||||
attributes: { userId: initialUserId, email: initialUserEmail },
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -137,21 +142,23 @@ export const mockSetEmailIdResponse = () => {
|
||||
export const mockSetCustomAttributeResponse = () => {
|
||||
fetchMock.mockResponseOnce(
|
||||
JSON.stringify({
|
||||
surveys: [],
|
||||
session: {
|
||||
id: sessionId,
|
||||
createdAt: "2021-03-09T15:00:00.000Z",
|
||||
updatedAt: "2021-03-09T15:00:00.000Z",
|
||||
expiresAt: expiryTime,
|
||||
},
|
||||
noCodeActionClasses: [],
|
||||
person: {
|
||||
id: initialPersonUid,
|
||||
environmentId,
|
||||
attributes: {
|
||||
userId: initialUserId,
|
||||
email: initialUserEmail,
|
||||
[customAttributeKey]: customAttributeValue,
|
||||
data: {
|
||||
surveys: [],
|
||||
session: {
|
||||
id: sessionId,
|
||||
createdAt: "2021-03-09T15:00:00.000Z",
|
||||
updatedAt: "2021-03-09T15:00:00.000Z",
|
||||
expiresAt: expiryTime,
|
||||
},
|
||||
noCodeActionClasses: [],
|
||||
person: {
|
||||
id: initialPersonUid,
|
||||
environmentId,
|
||||
attributes: {
|
||||
userId: initialUserId,
|
||||
email: initialUserEmail,
|
||||
[customAttributeKey]: customAttributeValue,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -161,21 +168,23 @@ export const mockSetCustomAttributeResponse = () => {
|
||||
export const mockUpdateEmailResponse = () => {
|
||||
fetchMock.mockResponseOnce(
|
||||
JSON.stringify({
|
||||
surveys: [],
|
||||
noCodeActionClasses: [],
|
||||
session: {
|
||||
id: sessionId,
|
||||
createdAt: "2021-03-09T15:00:00.000Z",
|
||||
updatedAt: "2021-03-09T15:00:00.000Z",
|
||||
expiresAt: expiryTime,
|
||||
},
|
||||
person: {
|
||||
id: initialPersonUid,
|
||||
environmentId,
|
||||
attributes: {
|
||||
userId: initialUserId,
|
||||
email: updatedUserEmail,
|
||||
[customAttributeKey]: customAttributeValue,
|
||||
data: {
|
||||
surveys: [],
|
||||
noCodeActionClasses: [],
|
||||
session: {
|
||||
id: sessionId,
|
||||
createdAt: "2021-03-09T15:00:00.000Z",
|
||||
updatedAt: "2021-03-09T15:00:00.000Z",
|
||||
expiresAt: expiryTime,
|
||||
},
|
||||
person: {
|
||||
id: initialPersonUid,
|
||||
environmentId,
|
||||
attributes: {
|
||||
userId: initialUserId,
|
||||
email: updatedUserEmail,
|
||||
[customAttributeKey]: customAttributeValue,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -208,22 +217,24 @@ export const mockRegisterRouteChangeResponse = () => {
|
||||
export const mockLogoutResponse = () => {
|
||||
fetchMock.mockResponseOnce(
|
||||
JSON.stringify({
|
||||
settings: {
|
||||
surveys: [],
|
||||
noCodeEvents: [],
|
||||
data: {
|
||||
settings: {
|
||||
surveys: [],
|
||||
noCodeEvents: [],
|
||||
},
|
||||
person: {
|
||||
id: newPersonUid,
|
||||
environmentId,
|
||||
attributes: [],
|
||||
},
|
||||
session: {
|
||||
id: sessionId,
|
||||
createdAt: "2021-03-09T15:00:00.000Z",
|
||||
updatedAt: "2021-03-09T15:00:00.000Z",
|
||||
expiresAt: expiryTime,
|
||||
},
|
||||
noCodeActionClasses: [],
|
||||
},
|
||||
person: {
|
||||
id: newPersonUid,
|
||||
environmentId,
|
||||
attributes: [],
|
||||
},
|
||||
session: {
|
||||
id: sessionId,
|
||||
createdAt: "2021-03-09T15:00:00.000Z",
|
||||
updatedAt: "2021-03-09T15:00:00.000Z",
|
||||
expiresAt: expiryTime,
|
||||
},
|
||||
noCodeActionClasses: [],
|
||||
})
|
||||
);
|
||||
console.log("Resetting person. Getting new person, session and settings from backend");
|
||||
|
||||
@@ -16,5 +16,5 @@ export const WEBAPP_URL =
|
||||
"http://localhost:3000";
|
||||
|
||||
// Other
|
||||
export const INTERNAL_SECRET = process.env.INTERNAL_SECRET;
|
||||
export const INTERNAL_SECRET = process.env.INTERNAL_SECRET || "";
|
||||
export const CRON_SECRET = process.env.CRON_SECRET;
|
||||
|
||||
@@ -42,10 +42,6 @@ export const getApiKeyFromKey = async (apiKey: string): Promise<TApiKey | null>
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiKeyData) {
|
||||
throw new ResourceNotFoundError("API Key", apiKey);
|
||||
}
|
||||
|
||||
return apiKeyData;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use server";
|
||||
import 'server-only'
|
||||
|
||||
import "server-only";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/errors";
|
||||
import { TAttributeClass } from "@formbricks/types/v1/attributeClasses";
|
||||
@@ -55,8 +54,6 @@ export const updatetAttributeClass = async (
|
||||
|
||||
return transformedAttributeClass;
|
||||
} catch (error) {
|
||||
throw new DatabaseError(
|
||||
`Database error when updating attribute class with id ${attributeClassId}`
|
||||
);
|
||||
throw new DatabaseError(`Database error when updating attribute class with id ${attributeClassId}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -37,3 +37,43 @@ export const getEnvironment = cache(async (environmentId: string): Promise<TEnvi
|
||||
throw new ValidationError("Data validation of environment failed");
|
||||
}
|
||||
});
|
||||
|
||||
export const getEnvironments = cache(
|
||||
async (productId: string): Promise<TEnvironment[]> => {
|
||||
let productPrisma;
|
||||
try {
|
||||
productPrisma = await prisma.product.findFirst({
|
||||
where: {
|
||||
id: productId,
|
||||
},
|
||||
include:{
|
||||
environments:true
|
||||
}
|
||||
});
|
||||
|
||||
if (!productPrisma) {
|
||||
throw new ResourceNotFoundError("Product", productId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const environments:TEnvironment[]=[];
|
||||
for(let environment of productPrisma.environments){
|
||||
let targetEnvironment:TEnvironment=ZEnvironment.parse(environment);
|
||||
environments.push(targetEnvironment);
|
||||
}
|
||||
|
||||
try {
|
||||
return environments;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
console.error(JSON.stringify(error.errors, null, 2));
|
||||
}
|
||||
throw new ValidationError("Data validation of environments array failed");
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -41,3 +41,4 @@ export const getProductByEnvironmentId = cache(async (environmentId: string): Pr
|
||||
throw new ValidationError("Data validation of product failed");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors";
|
||||
import { TPerson } from "@formbricks/types/v1/people";
|
||||
import { TResponse, TResponseInput, TResponseUpdateInput } from "@formbricks/types/v1/responses";
|
||||
import { TTag } from "@formbricks/types/v1/tags";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache } from "react";
|
||||
import "server-only";
|
||||
import { getPerson, transformPrismaPerson } from "./person";
|
||||
import { cache } from "react";
|
||||
import { TPerson } from "@formbricks/types/v1/people";
|
||||
|
||||
const responseSelection = {
|
||||
id: true,
|
||||
@@ -63,7 +63,7 @@ const responseSelection = {
|
||||
},
|
||||
};
|
||||
|
||||
export const createResponse = async (responseInput: TResponseInput): Promise<TResponse> => {
|
||||
export const createResponse = async (responseInput: Partial<TResponseInput>): Promise<TResponse> => {
|
||||
try {
|
||||
let person: TPerson | null = null;
|
||||
|
||||
|
||||
+200
-40
@@ -1,13 +1,13 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { z } from "zod";
|
||||
import { ValidationError } from "@formbricks/errors";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors";
|
||||
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/errors";
|
||||
import { TSurvey, TSurveyWithAnalytics, ZSurvey, ZSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import "server-only";
|
||||
import { cache } from "react";
|
||||
import "server-only";
|
||||
import { z } from "zod";
|
||||
import { captureTelemetry } from "../telemetry";
|
||||
|
||||
export const select = {
|
||||
export const selectSurveyWithAnalytics = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
@@ -23,6 +23,61 @@ export const select = {
|
||||
closeOnDate: true,
|
||||
delay: true,
|
||||
autoComplete: true,
|
||||
redirectUrl: true,
|
||||
triggers: {
|
||||
select: {
|
||||
eventClass: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
environmentId: true,
|
||||
name: true,
|
||||
description: true,
|
||||
type: true,
|
||||
noCodeConfig: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
attributeFilters: {
|
||||
select: {
|
||||
id: true,
|
||||
attributeClassId: true,
|
||||
condition: true,
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
displays: {
|
||||
select: {
|
||||
status: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
responses: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const selectSurvey = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
type: true,
|
||||
environmentId: true,
|
||||
status: true,
|
||||
questions: true,
|
||||
thankYouCard: true,
|
||||
displayOption: true,
|
||||
recontactDays: true,
|
||||
autoClose: true,
|
||||
closeOnDate: true,
|
||||
delay: true,
|
||||
autoComplete: true,
|
||||
redirectUrl: true,
|
||||
triggers: {
|
||||
select: {
|
||||
eventClass: {
|
||||
@@ -49,18 +104,70 @@ export const select = {
|
||||
},
|
||||
};
|
||||
|
||||
export const preloadSurvey = (surveyId: string) => {
|
||||
void getSurvey(surveyId);
|
||||
export const preloadSurveyWithAnalytics = (surveyId: string) => {
|
||||
void getSurveyWithAnalytics(surveyId);
|
||||
};
|
||||
|
||||
export const getSurvey = cache(async (surveyId: string): Promise<TSurveyWithAnalytics | null> => {
|
||||
export const getSurveyWithAnalytics = cache(
|
||||
async (surveyId: string): Promise<TSurveyWithAnalytics | null> => {
|
||||
let surveyPrisma;
|
||||
try {
|
||||
surveyPrisma = await prisma.survey.findUnique({
|
||||
where: {
|
||||
id: surveyId,
|
||||
},
|
||||
select: selectSurveyWithAnalytics,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!surveyPrisma) {
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
let { _count, displays, ...surveyPrismaFields } = surveyPrisma;
|
||||
|
||||
const numDisplays = displays.length;
|
||||
const numDisplaysResponded = displays.filter((item) => item.status === "responded").length;
|
||||
const numResponses = _count.responses;
|
||||
// responseRate, rounded to 2 decimal places
|
||||
const responseRate = numDisplays ? Math.round((numDisplaysResponded / numDisplays) * 100) / 100 : 0;
|
||||
|
||||
const transformedSurvey = {
|
||||
...surveyPrismaFields,
|
||||
triggers: surveyPrismaFields.triggers.map((trigger) => trigger.eventClass),
|
||||
analytics: {
|
||||
numDisplays,
|
||||
responseRate,
|
||||
numResponses,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const survey = ZSurveyWithAnalytics.parse(transformedSurvey);
|
||||
return survey;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
console.error(JSON.stringify(error.errors, null, 2)); // log the detailed error information
|
||||
}
|
||||
throw new ValidationError("Data validation of survey failed");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getSurvey = cache(async (surveyId: string): Promise<TSurvey | null> => {
|
||||
let surveyPrisma;
|
||||
try {
|
||||
surveyPrisma = await prisma.survey.findUnique({
|
||||
where: {
|
||||
id: surveyId,
|
||||
},
|
||||
select,
|
||||
select: selectSurvey,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
@@ -71,36 +178,16 @@ export const getSurvey = cache(async (surveyId: string): Promise<TSurveyWithAnal
|
||||
}
|
||||
|
||||
if (!surveyPrisma) {
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const numDisplays = await prisma.display.count({
|
||||
where: {
|
||||
surveyId,
|
||||
},
|
||||
});
|
||||
|
||||
const numDisplaysResponded = await prisma.display.count({
|
||||
where: {
|
||||
surveyId,
|
||||
status: "responded",
|
||||
},
|
||||
});
|
||||
|
||||
// responseRate, rounded to 2 decimal places
|
||||
const responseRate = numDisplays ? Math.round((numDisplaysResponded / numDisplays) * 100) / 100 : 0;
|
||||
|
||||
const transformedSurvey = {
|
||||
...surveyPrisma,
|
||||
triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass),
|
||||
analytics: {
|
||||
numDisplays,
|
||||
responseRate,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const survey = ZSurveyWithAnalytics.parse(transformedSurvey);
|
||||
const survey = ZSurvey.parse(transformedSurvey);
|
||||
return survey;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
@@ -117,7 +204,7 @@ export const getSurveys = cache(async (environmentId: string): Promise<TSurvey[]
|
||||
where: {
|
||||
environmentId,
|
||||
},
|
||||
select,
|
||||
select: selectSurvey,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
@@ -128,16 +215,16 @@ export const getSurveys = cache(async (environmentId: string): Promise<TSurvey[]
|
||||
}
|
||||
|
||||
const surveys: TSurvey[] = [];
|
||||
for (const surveyPrisma of surveysPrisma) {
|
||||
const transformedSurvey = {
|
||||
...surveyPrisma,
|
||||
triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass),
|
||||
};
|
||||
const survey = ZSurvey.parse(transformedSurvey);
|
||||
surveys.push(survey);
|
||||
}
|
||||
|
||||
try {
|
||||
for (const surveyPrisma of surveysPrisma) {
|
||||
const transformedSurvey = {
|
||||
...surveyPrisma,
|
||||
triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass),
|
||||
};
|
||||
const survey = ZSurvey.parse(transformedSurvey);
|
||||
surveys.push(survey);
|
||||
}
|
||||
return surveys;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
@@ -146,3 +233,76 @@ export const getSurveys = cache(async (environmentId: string): Promise<TSurvey[]
|
||||
throw new ValidationError("Data validation of survey failed");
|
||||
}
|
||||
});
|
||||
|
||||
export const getSurveysWithAnalytics = cache(
|
||||
async (environmentId: string): Promise<TSurveyWithAnalytics[]> => {
|
||||
let surveysPrisma;
|
||||
try {
|
||||
surveysPrisma = await prisma.survey.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
},
|
||||
select: selectSurveyWithAnalytics,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const surveys: TSurveyWithAnalytics[] = [];
|
||||
for (const { _count, displays, ...surveyPrisma } of surveysPrisma) {
|
||||
const numDisplays = displays.length;
|
||||
const numDisplaysResponded = displays.filter((item) => item.status === "responded").length;
|
||||
const responseRate = numDisplays ? Math.round((numDisplaysResponded / numDisplays) * 100) / 100 : 0;
|
||||
|
||||
const transformedSurvey = {
|
||||
...surveyPrisma,
|
||||
triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass),
|
||||
analytics: {
|
||||
numDisplays,
|
||||
responseRate,
|
||||
numResponses: _count.responses,
|
||||
},
|
||||
};
|
||||
const survey = ZSurveyWithAnalytics.parse(transformedSurvey);
|
||||
surveys.push(survey);
|
||||
}
|
||||
return surveys;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
console.error(JSON.stringify(error.errors, null, 2)); // log the detailed error information
|
||||
}
|
||||
throw new ValidationError("Data validation of survey failed");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export async function deleteSurvey(surveyId: string) {
|
||||
const deletedSurvey = await prisma.survey.delete({
|
||||
where: {
|
||||
id: surveyId,
|
||||
},
|
||||
select: selectSurvey,
|
||||
});
|
||||
return deletedSurvey;
|
||||
}
|
||||
|
||||
export async function createSurvey(environmentId: string, surveyBody: any) {
|
||||
const survey = await prisma.survey.create({
|
||||
data: {
|
||||
...surveyBody,
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
captureTelemetry("survey created");
|
||||
|
||||
return survey;
|
||||
}
|
||||
|
||||
@@ -94,3 +94,13 @@ export const getTodaysDateFormatted = (seperator: string) => {
|
||||
|
||||
return formattedDate;
|
||||
};
|
||||
|
||||
export function convertDatesInObject(obj: any) {
|
||||
for (let key in obj) {
|
||||
if (typeof obj[key] === "string" && !isNaN(Date.parse(obj[key]))) {
|
||||
obj[key] = new Date(obj[key]);
|
||||
} else if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||
convertDatesInObject(obj[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { z } from "zod";
|
||||
import { ZResponse } from "./responses";
|
||||
|
||||
export const ZPipelineTrigger = z.enum(["responseFinished", "responseCreated", "responseUpdated"]);
|
||||
|
||||
export type TPipelineTrigger = z.infer<typeof ZPipelineTrigger>;
|
||||
|
||||
export const ZPipelineInput = z.object({
|
||||
event: ZPipelineTrigger,
|
||||
response: ZResponse,
|
||||
environmentId: z.string(),
|
||||
surveyId: z.string(),
|
||||
});
|
||||
|
||||
export type TPipelineInput = z.infer<typeof ZPipelineInput>;
|
||||
|
||||
@@ -27,7 +27,17 @@ const ZResponseNote = z.object({
|
||||
|
||||
export type TResponseNote = z.infer<typeof ZResponseNote>;
|
||||
|
||||
const ZResponse = z.object({
|
||||
export const ZResponseMeta = z.object({
|
||||
userAgent: z.object({
|
||||
browser: z.string().optional(),
|
||||
os: z.string().optional(),
|
||||
device: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type TResponseMeta = z.infer<typeof ZResponseMeta>;
|
||||
|
||||
export const ZResponse = z.object({
|
||||
id: z.string().cuid2(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
@@ -45,6 +55,7 @@ const ZResponse = z.object({
|
||||
data: ZResponseData,
|
||||
notes: z.array(ZResponseNote),
|
||||
tags: z.array(ZTag),
|
||||
meta: ZResponseMeta.nullable(),
|
||||
});
|
||||
|
||||
export type TResponse = z.infer<typeof ZResponse>;
|
||||
|
||||
@@ -231,13 +231,14 @@ export const ZSurvey = z.object({
|
||||
displayOption: z.enum(["displayOnce", "displayMultiple", "respondMultiple"]),
|
||||
autoClose: z.union([z.number(), z.null()]),
|
||||
triggers: z.array(ZActionClass),
|
||||
redirectUrl: z.string().url().optional(),
|
||||
redirectUrl: z.string().url().nullable(),
|
||||
recontactDays: z.union([z.number(), z.null()]),
|
||||
questions: ZSurveyQuestions,
|
||||
thankYouCard: ZSurveyThankYouCard,
|
||||
delay: z.number(),
|
||||
autoComplete: z.union([z.number(), z.null()]),
|
||||
closeOnDate: z.date().nullable(),
|
||||
surveyClosedMessage: ZSurveyClosedMessage,
|
||||
});
|
||||
|
||||
export type TSurvey = z.infer<typeof ZSurvey>;
|
||||
@@ -246,8 +247,8 @@ export const ZSurveyWithAnalytics = ZSurvey.extend({
|
||||
analytics: z.object({
|
||||
numDisplays: z.number(),
|
||||
responseRate: z.number(),
|
||||
numResponses: z.number(),
|
||||
}),
|
||||
surveyClosedMessage: ZSurveyClosedMessage,
|
||||
});
|
||||
|
||||
export type TSurveyWithAnalytics = z.infer<typeof ZSurveyWithAnalytics>;
|
||||
|
||||
Reference in New Issue
Block a user