Merge branch 'main' of github.com:formbricks/formbricks into feature/integrations
2
.github/workflows/cron-weeklySummary.yml
vendored
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [
|
||||
|
||||
BIN
apps/formbricks-com/pages/docs/api/api-key-setup/add-api-key.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
@@ -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 won’t 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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
102
apps/formbricks-com/pages/docs/integrations/zapier/index.mdx
Normal 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 you’ll 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 you’ll 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 don’t 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>;
|
||||
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 33 KiB |
@@ -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.
|
||||
```
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
|
||||
export default function LoadingPage() {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
38
packages/lib/services/environment.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
43
packages/lib/services/product.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
12
packages/types/v1/environment.ts
Normal 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>;
|
||||
17
packages/types/v1/product.ts
Normal 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>;
|
||||
@@ -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}>
|
||||
|
||||