Merge branch 'main' of github.com:formbricks/formbricks into feature/integrations

This commit is contained in:
Johannes
2023-07-12 19:14:49 +02:00
45 changed files with 550 additions and 260 deletions

View File

@@ -19,5 +19,5 @@ jobs:
curl ${{ secrets.APP_URL }}/api/cron/weekly_summary \
-X POST \
-H 'content-type: application/json' \
-H 'authorization: ${{ secrets.CRON_SECRET }}' \
-H 'x-api-key: ${{ secrets.CRON_SECRET }}' \
--fail

View File

@@ -9,10 +9,9 @@ import { Button } from "@formbricks/ui";
import clsx from "clsx";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import MetaInformation from "../shared/MetaInformation";
import DocsFeedback from "./DocsFeedback";
import { useRef } from "react";
function GitHubIcon(props: any) {
return (
@@ -23,7 +22,6 @@ function GitHubIcon(props: any) {
}
function Header({ navigation }: any) {
const router = useRouter();
let [isScrolled, setIsScrolled] = useState(false);
useEffect(() => {
@@ -63,13 +61,15 @@ function Header({ navigation }: any) {
variant="secondary"
EndIcon={GitHubIcon}
endIconClassName="fill-slate-800 dark:fill-slate-200 ml-2"
onClick={() => router.push("https://github.com/formbricks/formbricks")}>
View on Github
href="https://github.com/formbricks/formbricks"
target="_blank">
Star us on Github
</Button>
<Button
variant="highlight"
className="ml-2"
onClick={() => router.push("https://app.formbricks.com/auth/signup")}>
href="https://app.formbricks.com/auth/signup"
target="_blank">
Get started
</Button>
</div>
@@ -101,6 +101,26 @@ export const Layout: React.FC<LayoutProps> = ({ children, meta }) => {
sessionStorage.setItem("scrollPosition", (scroll + 89).toString());
};
const useExternalLinks = (selector: string) => {
useEffect(() => {
const links = document.querySelectorAll(selector);
links.forEach((link) => {
link.setAttribute("target", "_blank");
link.setAttribute("rel", "noopener noreferrer");
});
return () => {
links.forEach((link) => {
link.removeAttribute("target");
link.removeAttribute("rel");
});
};
}, [selector]);
};
useExternalLinks(".prose a");
useEffect(() => {
if (parentRef.current) {
const scrollPosition = Number.parseInt(sessionStorage.getItem("scrollPosition"), 10);

View File

@@ -44,6 +44,10 @@ const navigation = [
{ title: "Docs Feedback", href: "/docs/best-practices/docs-feedback" },
],
},
{
title: "Integrations",
links: [{ title: "Zapier", href: "/docs/integrations/zapier" }],
},
{
title: "Link Surveys",
links: [

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -2,6 +2,10 @@ import { Layout } from "@/components/docs/Layout";
import { Fence } from "@/components/shared/Fence";
import { APILayout } from "@/components/shared/APILayout.tsx";
import { Callout } from "@/components/shared/Callout";
import Image from "next/image";
import AddApiKey from "./add-api-key.png";
import ApiKeySecret from "./api-key-secret.png";
export const meta = {
title: "API Key Setup",
@@ -16,9 +20,11 @@ The API requests are authorized with a personal API key. This API key gives you
### How to generate an API key
1. Go to your settings on [app.formbricks.com](https://app.formbricks.com).
2. Go to page “API keys”.
2. Go to page “API keys”
<Image src={AddApiKey} alt="Add API Key" quality="100" className="rounded-lg" />
3. Create a key for the development or production environment.
4. Copy the key immediately. You wont be able to see it again.
<Image src={ApiKeySecret} alt="API Key Secret" quality="100" className="rounded-lg" />
<Callout title="Store API key safely" type="warning">
Anyone who has your API key has full control over your account. For security reasons, you cannot view the

View File

@@ -10,7 +10,7 @@ export const meta = {
description: "To test in-app surveys, trigger actions and set attributes, you can use the Demo App.",
};
To play around with the user actions, you can use the Demo App. It's a simple React app that you can run locally and use to trigger actions and set attributes.
To play around with the in-app [User Actions](/docs/actions/why), you can use the Demo App. It's a simple React app that you can run locally and use to trigger actions and set [Attributes](/docs/attributes/why).
<Image src={DemoApp} alt="Demo App Preview" quality="100" className="rounded-lg" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -0,0 +1,102 @@
import { Layout } from "@/components/docs/Layout";
import { Fence } from "@/components/shared/Fence";
import { Callout } from "@/components/shared/Callout";
import Image from "next/image";
import AddNewZap from "./add-new-zap.png";
import ChooseEvent from "./choose-event.png";
import ConnectWithFB1 from "./connect-with-formbricks-1.png";
import ConnectWithFB2 from "./connect-with-formbricks-2.png";
import DuplicateSurvey from "./duplicate-survey.png";
import SelectSurvey from "./select-survey.png";
import SlackChannelMsg from "./slack-channel-msg.png";
import SlackMsg from "./slack-message.png";
import SubmitTestResponse from "./submit-test-response.png";
import SuccessConnection from "./success-connected.png";
import TestSubmission from "./test-submission.png";
import UpdateQuestionId from "./update-question-id.png";
import ZapierMessage from "./zapier-message.png";
export const meta = {
title: "Zapier Setup",
description: "Wire up Formbricks with Zapier and 5000+ other apps",
};
Zapier is a powerful ally. Hook up Formbricks with Zapier and you can send your data to 5000+ other apps. Here is how to do it.
<Callout title="Nail down your survey first" type="note">
Any changes in the survey cause additional work in the Zap. It makes sense to first settle on the survey you
want to run and then get to setting up Zapier.
</Callout>
## Step 1: Setup your survey incl. `questionId` for every question
When setting up the Zap your life will be easier when you change the `questionId`s of your survey questions. You can only do so **before** you publish your survey.
<Image src={UpdateQuestionId} alt="Update Question ID" quality="100" className="rounded-lg" />
_In every question card in the Advanced Settings you find the Question ID field. Update it so that youll recognize the response tied to this question._
<Callout title="Already published? Duplicate survey" type="note">
You can only update the questionId when the survey was not yet published. Already published it? Just
**duplicate it** to update the questionIds.
<Image src={DuplicateSurvey} alt="Duplicate Survey" quality="100" className="rounded-lg" />
</Callout>
## Step 3: Send a test response
In order to set up Zapier youll need a test response. This allows you to select the individual values of each response in your Zap. If you have Formbricks running locally and you want to set up an in-app survey, you can use our [Demo App](/docs/contributing/demo) to trigger a survey and submit a response.
<Image src={SubmitTestResponse} alt="Submit Test Response" quality="100" className="rounded-lg" />
## Step 4: Setup your Zap
Go to [zapier.com](https://zapier.com) and create a new Zap. Search for “Formbricks” to get started:
<Image src={AddNewZap} alt="Add New Zap" quality="100" className="rounded-lg" />
Then, choose the event you want to trigger the Zap on:
<Image src={ChooseEvent} alt="Choose Event" quality="100" className="rounded-lg" />
## Step 5: Connect Formbricks with Zapier
Now, you have to connect Zapier with Formbricks via an API Key:
<Image src={ConnectWithFB1} alt="Connect with Formbricks - 1" quality="100" className="rounded-lg" />
<Image src={ConnectWithFB2} alt="Connect with Formbricks - 2" quality="100" className="rounded-lg" />
Now you need an API key. Please refer to the [API Key Setup](/docs/api/api-key-setup) page to learn how to create one.
Once you copied it in the newly opened Zapier window, you will be connected:
<Image src={SuccessConnection} alt="Successful Connection" quality="100" className="rounded-lg" />
## Step 6: Select Survey
Next, you can choose from all the surveys you have created in this environment:
<Image src={SelectSurvey} alt="Select Survey" quality="100" className="rounded-lg" />
## Step 7: Test your trigger
Once you hit “Test” you will see the three most recent submissions for this survey. If you dont have any submissions in the survey, submit one to continue setting up your Zap:
<Image src={TestSubmission} alt="Test Submission" quality="100" className="rounded-lg" />
_Now you're happy that you updated the questionId's_
## Step 8: Set up your Zap
Now you have all the data you need at hand. The next steps depend on what you want to do with it. In this tutorial, we will send submissions to a Slack channel:
<Image src={SlackChannelMsg} alt="Slack Channel Message" quality="100" className="rounded-lg" />
In the action itself we can determine the data and layout of the message. Here, we only choose the submission data. You can also refer to the meta data of the submission and the [attributes](/docs/attributes/why) of the person who submitted the survey.
<Image src={SlackMsg} alt="Slack Message" quality="100" className="rounded-lg" />
We now receive a notifcation in our Slack channel whenever a Churn survey is completed:
<Image src={ZapierMessage} alt="Zapier Message" quality="100" className="rounded-lg" />
export default ({ children }) => <Layout meta={meta}>{children}</Layout>;

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -78,7 +78,7 @@ https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?single_select_question_id
### Multi Select Question (Checkbox)
```tsx
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?single_select_question_id=Sun%2CPalms%2CBeach
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?multi_select_question_id=Sun%2CPalms%2CBeach
// -> Selects three options "Sun, Palms and Beach" in the multi select question. The strings have to be identical to the options in your question.
```

View File

@@ -4,6 +4,7 @@ import { NextResponse } from "next/server";
import { AttributeClass } from "@prisma/client";
import { sendResponseFinishedEmail } from "@/lib/email";
import { Question } from "@formbricks/types/questions";
import { NotificationSettings } from "@formbricks/types/users";
export async function POST(request: Request) {
const { internalSecret, environmentId, surveyId, event, data } = await request.json();
@@ -104,14 +105,11 @@ export async function POST(request: Request) {
});
// filter all users that have email notifications enabled for this survey
const usersWithNotifications = users.filter((user) => {
if (!user.notificationSettings) {
return false;
const notificationSettings: NotificationSettings | null = user.notificationSettings;
if (notificationSettings?.alert && notificationSettings.alert[surveyId]) {
return true;
}
const notificationSettings = user.notificationSettings[surveyId];
if (!notificationSettings || !notificationSettings.responseFinished) {
return false;
}
return true;
return false;
});
if (usersWithNotifications.length > 0) {

View File

@@ -1,14 +1,15 @@
"use client";
import FormbricksSignature from "@/components/preview/FormbricksSignature";
import Modal from "@/components/preview/Modal";
import Progress from "@/components/preview/Progress";
import QuestionConditional from "@/components/preview/QuestionConditional";
import ThankYouCard from "@/components/preview/ThankYouCard";
import { useEnvironment } from "@/lib/environments/environments";
import { useProduct } from "@/lib/products/products";
import type { Logic, Question } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { useEffect, useRef, useState } from "react";
import type { TProduct } from "@formbricks/types/v1/product";
import type { TEnvironment } from "@formbricks/types/v1/environment";
interface PreviewSurveyProps {
setActiveQuestionId: (id: string | null) => void;
activeQuestionId?: string | null;
@@ -19,6 +20,8 @@ interface PreviewSurveyProps {
thankYouCard: Survey["thankYouCard"];
autoClose: Survey["autoClose"];
previewType?: "modal" | "fullwidth" | "email";
product: TProduct;
environment: TEnvironment;
}
export default function PreviewSurvey({
@@ -26,15 +29,13 @@ export default function PreviewSurvey({
activeQuestionId,
questions,
brandColor,
environmentId,
surveyType,
thankYouCard,
autoClose,
previewType,
product,
environment,
}: PreviewSurveyProps) {
const { environment } = useEnvironment(environmentId);
const { product } = useProduct(environmentId);
const [isModalOpen, setIsModalOpen] = useState(true);
const [progress, setProgress] = useState(0); // [0, 1]
const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false);

View File

@@ -32,12 +32,17 @@ 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";
export default function SurveysList({ environmentId }) {
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);
@@ -133,6 +138,8 @@ export default function SurveysList({ environmentId }) {
onTemplateClick={(template) => {
newSurveyFromTemplate(template);
}}
environment={environment}
product={product}
/>
</>
)}

View File

@@ -82,6 +82,7 @@ export default function LogicEditor({
cta: ["clicked", "skipped"],
consent: ["skipped", "accepted"],
};
const logicConditions: LogicConditions = {
submitted: {
label: "is submitted",
@@ -201,10 +202,9 @@ export default function LogicEditor({
};
const deleteLogic = (logicIdx: number) => {
const newLogic = !question.logic
? []
: (question.logic as Logic[]).filter((_: any, idx: number) => idx !== logicIdx);
updateQuestion(questionIdx, { logic: newLogic });
const updatedLogic = !question.logic ? [] : JSON.parse(JSON.stringify(question.logic));
updatedLogic.splice(logicIdx, 1);
updateQuestion(questionIdx, { logic: updatedLogic });
};
const truncate = (str: string, n: number) =>
@@ -225,9 +225,7 @@ export default function LogicEditor({
<BsArrowReturnRight className="h-4 w-4" />
<p className="text-slate-700">If this answer</p>
<Select
defaultValue={logic.condition}
onValueChange={(e) => updateLogic(logicIdx, { condition: e })}>
<Select value={logic.condition} onValueChange={(e) => updateLogic(logicIdx, { condition: e })}>
<SelectTrigger className="min-w-fit flex-1">
<SelectValue placeholder="Select condition" />
</SelectTrigger>
@@ -246,9 +244,7 @@ export default function LogicEditor({
{logic.condition && logicConditions[logic.condition].values != null && (
<div className="flex-1 basis-1/5">
{!logicConditions[logic.condition].multiSelect ? (
<Select
defaultValue={logic.value}
onValueChange={(e) => updateLogic(logicIdx, { value: e })}>
<Select value={logic.value} onValueChange={(e) => updateLogic(logicIdx, { value: e })}>
<SelectTrigger>
<SelectValue placeholder="Select match type" />
</SelectTrigger>
@@ -294,7 +290,7 @@ export default function LogicEditor({
<p className="text-slate-700">skip to</p>
<Select
defaultValue={logic.destination}
value={logic.destination}
onValueChange={(e) => updateLogic(logicIdx, { destination: e })}>
<SelectTrigger className="w-fit overflow-hidden ">
<SelectValue placeholder="Select question" />

View File

@@ -1,8 +1,9 @@
"use client";
import AdvancedSettings from "@/app/environments/[environmentId]/surveys/[surveyId]/edit/AdvancedSettings";
import { getQuestionTypeName } from "@/lib/questions";
import { cn } from "@formbricks/lib/cn";
import { QuestionType, type Question } from "@formbricks/types/questions";
import { QuestionType } from "@formbricks/types/questions";
import type { Survey } from "@formbricks/types/surveys";
import { Input, Label, Switch } from "@formbricks/ui";
import {
@@ -20,18 +21,16 @@ import * as Collapsible from "@radix-ui/react-collapsible";
import { useState } from "react";
import { Draggable } from "react-beautiful-dnd";
import CTAQuestionForm from "./CTAQuestionForm";
import ConsentQuestionForm from "./ConsentQuestionForm";
import MultipleChoiceMultiForm from "./MultipleChoiceMultiForm";
import MultipleChoiceSingleForm from "./MultipleChoiceSingleForm";
import NPSQuestionForm from "./NPSQuestionForm";
import OpenQuestionForm from "./OpenQuestionForm";
import QuestionDropdown from "./QuestionMenu";
import RatingQuestionForm from "./RatingQuestionForm";
import ConsentQuestionForm from "./ConsentQuestionForm";
import AdvancedSettings from "@/app/environments/[environmentId]/surveys/[surveyId]/edit/AdvancedSettings";
interface QuestionCardProps {
localSurvey: Survey;
question: Question;
questionIdx: number;
moveQuestion: (questionIndex: number, up: boolean) => void;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
@@ -44,7 +43,6 @@ interface QuestionCardProps {
export default function QuestionCard({
localSurvey,
question,
questionIdx,
moveQuestion,
updateQuestion,
@@ -54,6 +52,7 @@ export default function QuestionCard({
setActiveQuestionId,
lastQuestion,
}: QuestionCardProps) {
const question = localSurvey.questions[questionIdx];
const open = activeQuestionId === question.id;
const [openAdvanced, setOpenAdvanced] = useState(question.logic && question.logic.length > 0);
return (

View File

@@ -32,35 +32,45 @@ export default function QuestionsView({
}, {});
}, []);
const handleQuestionLogicChange = (survey: Survey, compareId: string, updatedId: string): Survey => {
survey.questions.forEach((question) => {
if (!question.logic) return;
question.logic.forEach((rule) => {
if (rule.destination === compareId) {
rule.destination = updatedId;
}
});
});
return survey;
};
const updateQuestion = (questionIdx: number, updatedAttributes: any) => {
const updatedSurvey = JSON.parse(JSON.stringify(localSurvey));
updatedSurvey.questions[questionIdx] = {
...updatedSurvey.questions[questionIdx],
...updatedAttributes,
};
setLocalSurvey(updatedSurvey);
let updatedSurvey = JSON.parse(JSON.stringify(localSurvey));
if ("id" in updatedAttributes) {
// if the survey whose id is to be changed is linked to logic of any other survey then changing it
const initialQuestionId = updatedSurvey.questions[questionIdx].id;
updatedSurvey = handleQuestionLogicChange(updatedSurvey, initialQuestionId, updatedAttributes.id);
// relink the question to internal Id
internalQuestionIdMap[updatedAttributes.id] =
internalQuestionIdMap[localSurvey.questions[questionIdx].id];
delete internalQuestionIdMap[localSurvey.questions[questionIdx].id];
setActiveQuestionId(updatedAttributes.id);
}
updatedSurvey.questions[questionIdx] = {
...updatedSurvey.questions[questionIdx],
...updatedAttributes,
};
setLocalSurvey(updatedSurvey);
};
const deleteQuestion = (questionIdx: number) => {
const questionId = localSurvey.questions[questionIdx].id;
const updatedSurvey: Survey = JSON.parse(JSON.stringify(localSurvey));
let updatedSurvey: Survey = JSON.parse(JSON.stringify(localSurvey));
updatedSurvey.questions.splice(questionIdx, 1);
updatedSurvey.questions.forEach((question) => {
if (!question.logic) return;
question.logic.forEach((rule) => {
if (rule.destination === questionId) {
rule.destination = "end";
}
});
});
updatedSurvey = handleQuestionLogicChange(updatedSurvey, questionId, "end");
setLocalSurvey(updatedSurvey);
delete internalQuestionIdMap[questionId];
@@ -141,7 +151,6 @@ export default function QuestionsView({
<QuestionCard
key={internalQuestionIdMap[question.id]}
localSurvey={localSurvey}
question={question}
questionIdx={questionIdx}
moveQuestion={moveQuestion}
updateQuestion={updateQuestion}

View File

@@ -1,7 +1,7 @@
"use client";
import type { Survey } from "@formbricks/types/surveys";
import { Input, Label, Switch, DatePicker } from "@formbricks/ui";
import { DatePicker, Input, Label, Switch } from "@formbricks/ui";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useEffect, useState } from "react";
@@ -145,130 +145,44 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
<Collapsible.CollapsibleContent>
<hr className="py-1 text-slate-600" />
<div className="p-3">
<div className="ml-2 flex items-center space-x-1 p-4">
<Switch id="autoComplete" checked={autoComplete} onCheckedChange={handleCheckMark} />
<Label htmlFor="autoComplete" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">
Auto complete survey on response limit
</h3>
</div>
</Label>
</div>
{autoComplete && (
<div className="ml-2 flex items-center space-x-1 px-4 pb-4">
<label
htmlFor="autoCompleteResponses"
className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
<div className="">
<p className="text-sm font-semibold text-slate-700">
Automatically mark the survey as complete after
<Input
type="number"
min="1"
id="autoCompleteResponses"
value={localSurvey.autoComplete?.toString()}
onChange={(e) => handleInputResponse(e)}
className="ml-2 mr-2 inline w-16 text-center text-sm"
/>
completed responses.
</p>
</div>
</label>
</div>
)}
{localSurvey.type === "link" && (
<>
<div className="ml-2 flex items-center space-x-1 p-4">
<Switch id="redirectUrl" checked={redirectToggle} onCheckedChange={handleRedirectCheckMark} />
<Label htmlFor="redirectUrl" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Redirect on completion</h3>
<p className="text-xs font-normal text-slate-500">
Redirect user to specified link on survey completion
</p>
</div>
</Label>
</div>
{redirectToggle && (
<div className="ml-2 space-x-1 px-4 pb-4">
<Input
type="url"
placeholder="https://www.example.com"
value={redirectUrl ? redirectUrl : ""}
onChange={(e) => handleRedirectUrlChange(e.target.value)}
/>
</div>
)}
<div className="ml-2 flex items-center space-x-1 p-4">
<Switch
id="redirectUrl"
checked={surveyClosedMessageToggle}
onCheckedChange={handleCloseSurveyMessageToggle}
/>
<Label htmlFor="redirectUrl" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">
{"Adjust 'Survey Closed' Message"}
</h3>
<p className="text-xs font-normal text-slate-500">
Change the message visitors see when the survey is closed.
</p>
</div>
</Label>
</div>
{surveyClosedMessageToggle && (
<div className="ml-2 space-x-1 px-4 pb-4">
<div>
<Label htmlFor="headline">Heading</Label>
<div className="mt-2">
<Input
autoFocus
id="heading"
name="heading"
defaultValue={surveyClosedMessage.heading}
onChange={(e) => handleClosedSurveyMessageChange({ heading: e.target.value })}
/>
</div>
</div>
<div className="mt-3">
<Label htmlFor="headline">Subheading</Label>
<div className="mt-2">
<Input
id="subheading"
name="subheading"
defaultValue={surveyClosedMessage.subheading}
onChange={(e) => handleClosedSurveyMessageChange({ subheading: e.target.value })}
/>
</div>
</div>
</div>
)}
</>
)}
<div className="p-3 ">
{/* Close Survey on Limit */}
<div className="p-3">
<div className="ml-2 flex items-center space-x-1">
<Switch id="redirectUrl" checked={redirectToggle} onCheckedChange={handleRedirectCheckMark} />
<Label htmlFor="redirectUrl" className="cursor-pointer">
<Switch id="autoComplete" checked={autoComplete} onCheckedChange={handleCheckMark} />
<Label htmlFor="autoComplete" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Redirect on completion</h3>
<h3 className="text-sm font-semibold text-slate-700">Close survey on response limit</h3>
<p className="text-xs font-normal text-slate-500">
Redirect user to specified link on survey completion
Automatically close the survey after a certain number of responses.
</p>
</div>
</Label>
</div>
{redirectToggle && (
<div className="mt-4">
<Input
type="url"
placeholder="https://www.example.com"
value={redirectUrl ? redirectUrl : ""}
onChange={(e) => handleRedirectUrlChange(e.target.value)}
/>
{autoComplete && (
<div className="ml-2 mt-4 flex items-center space-x-1 pb-4">
<label
htmlFor="autoCompleteResponses"
className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
<div className="">
<p className="text-sm font-semibold text-slate-700">
Automatically mark the survey as complete after
<Input
autoFocus
type="number"
min="1"
id="autoCompleteResponses"
value={localSurvey.autoComplete?.toString()}
onChange={(e) => handleInputResponse(e)}
className="ml-2 mr-2 inline w-16 bg-white text-center text-sm"
/>
completed responses.
</p>
</div>
</label>
</div>
)}
</div>
{/* Close Survey on Date */}
<div className="p-3 ">
<div className="ml-2 flex items-center space-x-1">
<Switch
@@ -279,21 +193,112 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
<Label htmlFor="surveyDeadline" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Close survey on date</h3>
<p className="text-xs font-normal text-slate-500">
Automatically closes the survey at the beginning of the day (UTC).
</p>
{localSurvey.status === "completed" && (
<p className="text-xs font-normal text-slate-500">
This form is already completed. You can change the status settings to make best use of
this option.
</p>
<p className="text-xs font-normal text-slate-500">This form is already completed.</p>
)}
</div>
</Label>
</div>
{surveyCloseOnDateToggle && (
<div className="mt-4">
<DatePicker date={closeOnDate} handleDateChange={handleCloseOnDateChange} />
<div className="ml-2 mt-4 flex items-center space-x-1 pb-4">
<div className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
<p className="mr-2 text-sm font-semibold text-slate-700">
Automatically mark survey as complete on:
</p>
<DatePicker date={closeOnDate} handleDateChange={handleCloseOnDateChange} />
</div>
</div>
)}
</div>
{/* Redirect on completion */}
{localSurvey.type === "link" && (
<>
<div className="p-3 ">
<div className="ml-2 flex items-center space-x-1">
<Switch
id="redirectUrl"
checked={redirectToggle}
onCheckedChange={handleRedirectCheckMark}
/>
<Label htmlFor="redirectUrl" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Redirect on completion</h3>
<p className="text-xs font-normal text-slate-500">
Redirect user to specified link on survey completion
</p>
</div>
</Label>
</div>
{redirectToggle && (
<div className="ml-2 mt-4 flex items-center space-x-1 pb-4">
<div className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
<p className="mr-2 whitespace-nowrap text-sm font-semibold text-slate-700">
Redirect respondents here:
</p>
<Input
autoFocus
className="bg-white"
type="url"
placeholder="https://www.example.com"
value={redirectUrl ? redirectUrl : ""}
onChange={(e) => handleRedirectUrlChange(e.target.value)}
/>
</div>
</div>
)}
</div>
{/* Adjust Survey Closed Message */}
<div className="p-3 ">
<div className="ml-2 flex items-center space-x-1">
<Switch
id="adjustSurveyClosedMessage"
checked={surveyClosedMessageToggle}
onCheckedChange={handleCloseSurveyMessageToggle}
/>
<Label htmlFor="adjustSurveyClosedMessage" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">
{" "}
{"Adjust 'Survey Closed' message"}
</h3>
<p className="text-xs font-normal text-slate-500">
Change the message visitors see when the survey is closed.
</p>
</div>
</Label>
</div>
{surveyClosedMessageToggle && (
<div className="ml-2 mt-4 flex items-center space-x-1 pb-4">
<div className="w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
<Label htmlFor="headline">Heading</Label>
<Input
autoFocus
id="heading"
className="mb-4 mt-2 bg-white"
name="heading"
defaultValue={surveyClosedMessage.heading}
onChange={(e) => handleClosedSurveyMessageChange({ heading: e.target.value })}
/>
<Label htmlFor="headline">Subheading</Label>
<Input
className="mt-2 bg-white"
id="subheading"
name="subheading"
defaultValue={surveyClosedMessage.subheading}
onChange={(e) => handleClosedSurveyMessageChange({ subheading: e.target.value })}
/>
</div>
</div>
)}
</div>
</>
)}
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>

View File

@@ -11,6 +11,7 @@ import SettingsView from "./SettingsView";
import QuestionsAudienceTabs from "./QuestionsAudienceTabs";
import QuestionsView from "./QuestionsView";
import SurveyMenuBar from "./SurveyMenuBar";
import { useEnvironment } from "@/lib/environments/environments";
interface SurveyEditorProps {
environmentId: string;
@@ -24,6 +25,7 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId);
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
const { environment, isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
useEffect(() => {
if (survey) {
@@ -37,11 +39,11 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
}
}, [survey]);
if (isLoadingSurvey || isLoadingProduct || !localSurvey) {
if (isLoadingSurvey || isLoadingProduct || isLoadingEnvironment || !localSurvey) {
return <LoadingSpinner />;
}
if (isErrorSurvey || isErrorProduct) {
if (isErrorSurvey || isErrorProduct || isErrorEnvironment) {
return <ErrorComponent />;
}
@@ -81,6 +83,8 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
questions={localSurvey.questions}
brandColor={product.brandColor}
environmentId={environmentId}
product={product}
environment={environment}
surveyType={localSurvey.type}
thankYouCard={localSurvey.thankYouCard}
previewType={localSurvey.type === "web" ? "modal" : "fullwidth"}

View File

@@ -1,11 +1,15 @@
import ContentWrapper from "@/components/shared/ContentWrapper";
import WidgetStatusIndicator from "@/components/shared/WidgetStatusIndicator";
import SurveysList from "./SurveyList";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
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} />
<SurveysList environmentId={params.environmentId} product={product} />
<WidgetStatusIndicator environmentId={params.environmentId} type="mini" />
</ContentWrapper>
);

View File

@@ -0,0 +1,72 @@
"use client";
import { useState } from "react";
import type { Template } from "@formbricks/types/templates";
import { useEffect } from "react";
import { replacePresetPlaceholders } from "@/lib/templates";
import { templates } from "./templates";
import PreviewSurvey from "../PreviewSurvey";
import TemplateList from "./TemplateList";
import type { TProduct } from "@formbricks/types/v1/product";
import type { TEnvironment } from "@formbricks/types/v1/environment";
type TemplateContainerWithPreviewProps = {
environmentId: string;
product: TProduct;
environment: TEnvironment;
};
export default function TemplateContainerWithPreview({
environmentId,
product,
environment,
}: TemplateContainerWithPreviewProps) {
const [activeTemplate, setActiveTemplate] = useState<Template | null>(null);
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
useEffect(() => {
if (product && templates?.length) {
const newTemplate = replacePresetPlaceholders(templates[0], product);
setActiveTemplate(newTemplate);
setActiveQuestionId(newTemplate.preset.questions[0].id);
}
}, [product]);
return (
<div className="flex h-full flex-col ">
<div className="relative z-0 flex flex-1 overflow-hidden">
<div className="flex-1 flex-col overflow-auto bg-slate-50">
<h1 className="ml-6 mt-6 text-2xl font-bold text-slate-800">Create a new survey</h1>
<TemplateList
environmentId={environmentId}
environment={environment}
product={product}
onTemplateClick={(template) => {
setActiveQuestionId(template.preset.questions[0].id);
setActiveTemplate(template);
}}
/>
</div>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 md:flex md:flex-col">
{activeTemplate && (
<div className="my-6 flex h-full w-full flex-col items-center justify-center">
<p className="pb-2 text-center text-sm font-normal text-slate-400">Preview</p>
<PreviewSurvey
activeQuestionId={activeQuestionId}
questions={activeTemplate.preset.questions}
brandColor={product.brandColor}
product={product}
environment={environment}
setActiveQuestionId={setActiveQuestionId}
environmentId={environmentId}
surveyType={environment?.widgetSetupCompleted ? "web" : "link"}
thankYouCard={{ enabled: true }}
autoClose={null}
/>
</div>
)}
</aside>
</div>
</div>
);
}

View File

@@ -1,9 +1,5 @@
"use client";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useEnvironment } from "@/lib/environments/environments";
import { useProduct } from "@/lib/products/products";
import { useProfile } from "@/lib/profile";
import { createSurvey } from "@/lib/surveys/surveys";
import { replacePresetPlaceholders } from "@/lib/templates";
import { cn } from "@formbricks/lib/cn";
@@ -16,24 +12,26 @@ 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";
type TemplateList = {
environmentId: string;
onTemplateClick: (template: Template) => void;
environment: TEnvironment;
product: TProduct;
};
const ALL_CATEGORY_NAME = "All";
const RECOMMENDED_CATEGORY_NAME = "For you";
export default function TemplateList({ environmentId, onTemplateClick }: TemplateList) {
export default function TemplateList({ environmentId, onTemplateClick, product, environment }: TemplateList) {
const router = useRouter();
const [activeTemplate, setActiveTemplate] = useState<Template | null>(null);
const [loading, setLoading] = useState(false);
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
const { profile, isLoadingProfile, isErrorProfile } = useProfile();
const { environment } = useEnvironment(environmentId);
const [selectedFilter, setSelectedFilter] = useState(RECOMMENDED_CATEGORY_NAME);
const { profile, isLoadingProfile, isErrorProfile } = useProfile();
const [categories, setCategories] = useState<Array<string>>([]);
@@ -65,8 +63,8 @@ export default function TemplateList({ environmentId, onTemplateClick }: Templat
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
};
if (isLoadingProduct || isLoadingProfile) return <LoadingSpinner />;
if (isErrorProduct || isErrorProfile) return <ErrorComponent />;
if (isLoadingProfile) return <LoadingSpinner />;
if (isErrorProfile) return <ErrorComponent />;
return (
<main className="relative z-0 flex-1 overflow-y-auto px-6 pb-6 pt-3 focus:outline-none">

View File

@@ -0,0 +1,5 @@
import LoadingSpinner from "@/components/shared/LoadingSpinner";
export default function LoadingPage() {
return <LoadingSpinner />;
}

View File

@@ -1,67 +1,13 @@
"use client";
import TemplateContainerWithPreview from "./TemplateContainer";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useEnvironment } from "@/lib/environments/environments";
import { useProduct } from "@/lib/products/products";
import { replacePresetPlaceholders } from "@/lib/templates";
import type { Template } from "@formbricks/types/templates";
import { ErrorComponent } from "@formbricks/ui";
import { useEffect, useState } from "react";
import PreviewSurvey from "../PreviewSurvey";
import TemplateList from "./TemplateList";
import { templates } from "./templates";
export default function SurveyTemplatesPage({ params }) {
export default async function SurveyTemplatesPage({ params }) {
const environmentId = params.environmentId;
const [activeTemplate, setActiveTemplate] = useState<Template | null>(null);
const { environment, isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
useEffect(() => {
if (product && templates?.length) {
const newTemplate = replacePresetPlaceholders(templates[0], product);
setActiveTemplate(newTemplate);
setActiveQuestionId(newTemplate.preset.questions[0].id);
}
}, [product]);
if (isLoadingProduct || isLoadingEnvironment) return <LoadingSpinner />;
if (isErrorProduct || isErrorEnvironment) return <ErrorComponent />;
const environment = await getEnvironment(environmentId);
const product = await getProductByEnvironmentId(environmentId);
return (
<div className="flex h-full flex-col ">
<div className="relative z-0 flex flex-1 overflow-hidden">
<div className="flex-1 flex-col overflow-auto bg-slate-50">
<h1 className="ml-6 mt-6 text-2xl font-bold text-slate-800">Create a new survey</h1>
<TemplateList
environmentId={environmentId}
onTemplateClick={(template) => {
setActiveQuestionId(template.preset.questions[0].id);
setActiveTemplate(template);
}}
/>
</div>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 md:flex md:flex-col">
{activeTemplate && (
<div className="my-6 flex h-full w-full flex-col items-center justify-center">
<p className="pb-2 text-center text-sm font-normal text-slate-400">Preview</p>
<PreviewSurvey
activeQuestionId={activeQuestionId}
questions={activeTemplate.preset.questions}
brandColor={product.brandColor}
setActiveQuestionId={setActiveQuestionId}
environmentId={environmentId}
surveyType={environment?.widgetSetupCompleted ? "web" : "link"}
thankYouCard={{ enabled: true }}
autoClose={null}
/>
</div>
)}
</aside>
</div>
</div>
<TemplateContainerWithPreview environmentId={environmentId} environment={environment} product={product} />
);
}

View File

@@ -1658,7 +1658,7 @@ export const templates: Template[] = [
objectives: ["increase_user_adoption"],
description: "Identify the ONE thing your users want the most and build it.",
preset: {
name: "{{productName} Roadmap Input",
name: "{{productName}} Roadmap Input",
questions: [
{
id: createId(),

View File

@@ -217,6 +217,10 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
delete data.responseRate;
delete data.numDisplays;
if (data.surveyClosedMessage === null) {
data.surveyClosedMessage = prismaClient.JsonNull;
}
const prismaRes = await prisma.survey.update({
where: { id: surveyId },
data,

View File

@@ -37,7 +37,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
},
},
});
if (membership?.role !== "owner") {
if (membership?.role !== "owner" && membership?.role !== "admin") {
return res.status(403).json({ message: "You are not allowed to update this team" });
}

View File

@@ -70,7 +70,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
},
},
});
if (membership?.role !== "owner" || membership?.role !== "owner") {
if (membership?.role !== "owner" && membership?.role !== "admin") {
return res.status(403).json({ message: "You are not allowed to delete members from this team" });
}

View File

@@ -64,7 +64,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
},
},
});
if (membership?.role !== "owner") {
if (membership?.role !== "owner" && membership?.role !== "admin") {
return res.status(403).json({ message: "You are not allowed to delete member froms this team" });
} else if (membership?.role === "owner" && userId === currentUser.id) {
return res.status(403).json({ message: "You cannot delete yourself from this team" });

View File

@@ -0,0 +1,38 @@
import "server-only";
import { prisma } from "@formbricks/database";
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/errors";
import { ZEnvironment } from "@formbricks/types/v1/environment";
import type { TEnvironment } from "@formbricks/types/v1/environment";
import { cache } from "react";
export const getEnvironment = cache(async (environmentId: string): Promise<TEnvironment | null> => {
let environmentPrisma;
try {
environmentPrisma = await prisma.environment.findUnique({
where: {
id: environmentId,
},
});
if (!environmentPrisma) {
throw new ResourceNotFoundError("Environment", environmentId);
}
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
try {
const environment = ZEnvironment.parse(environmentPrisma);
return environment;
} catch (error) {
if (error instanceof z.ZodError) {
console.error(JSON.stringify(error.errors, null, 2));
}
throw new ValidationError("Data validation of environment failed");
}
});

View File

@@ -0,0 +1,43 @@
import "server-only";
import { prisma } from "@formbricks/database";
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/errors";
import { ZProduct } from "@formbricks/types/v1/product";
import type { TProduct } from "@formbricks/types/v1/product";
import { cache } from "react";
export const getProductByEnvironmentId = cache(async (environmentId: string): Promise<TProduct> => {
let productPrisma;
try {
productPrisma = await prisma.product.findFirst({
where: {
environments: {
some: {
id: environmentId,
},
},
},
});
if (!productPrisma) {
throw new ResourceNotFoundError("Product for Environment", environmentId);
}
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
try {
const product = ZProduct.parse(productPrisma);
return product;
} catch (error) {
if (error instanceof z.ZodError) {
console.error(JSON.stringify(error.errors, null, 2));
}
throw new ValidationError("Data validation of product failed");
}
});

View File

@@ -0,0 +1,12 @@
import { z } from "zod";
export const ZEnvironment: any = z.object({
id: z.string().cuid2(),
createdAt: z.date(),
updatedAt: z.date(),
type: z.enum(["development", "production"]),
productId: z.string(),
widgetSetupCompleted: z.boolean(),
});
export type TEnvironment = z.infer<typeof ZEnvironment>;

View File

@@ -0,0 +1,17 @@
import { z } from "zod";
export const ZProduct = z.object({
id: z.string().cuid2(),
createdAt: z.date(),
updatedAt: z.date(),
name: z.string(),
teamId: z.string(),
brandColor: z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/),
recontactDays: z.number().int(),
formbricksSignature: z.boolean(),
placement: z.enum(["bottomLeft", "bottomRight", "topLeft", "topRight", "center"]),
clickOutsideClose: z.boolean(),
darkOverlay: z.boolean(),
});
export type TProduct = z.infer<typeof ZProduct>;

View File

@@ -31,7 +31,7 @@ export function DatePicker({
<Button
variant={"minimal"}
className={cn(
"w-[280px] justify-start border border-slate-300 text-left font-normal",
"w-[280px] justify-start border border-slate-300 bg-white text-left font-normal",
!formattedDate && "text-muted-foreground"
)}
ref={btnRef}>