mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-24 19:48:23 -05:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 623e82ff4d | |||
| 33543f59f8 | |||
| 47826a45aa | |||
| 6f043ec16e | |||
| c2703788ae | |||
| ef7df0fc77 | |||
| cdb8199199 | |||
| b0ded570ff | |||
| 5c0b29eed4 | |||
| 8e16d8daf6 | |||
| deea760a17 | |||
| f56f08e3c1 | |||
| 5daeab6554 | |||
| 0d11c08be7 | |||
| e7fbdb4d00 | |||
| 9538c2e6e3 | |||
| 3b5f9adcd1 | |||
| 90480317af | |||
| 352e905529 | |||
| 588768c849 | |||
| 2bab855b05 | |||
| 7fdc2eec34 | |||
| 464455be2b | |||
| 0a11c6aed5 | |||
| 8566b4c3da | |||
| 9465bd15f2 | |||
| 37791fc78f | |||
| 98c16eb4b8 | |||
| 7803de6ee7 | |||
| d9e2267a3a |
@@ -67,7 +67,19 @@ git clone https://github.com/formbricks/formbricks && cd formbricks
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
2. Install Node.JS packages via pnpm. Don't have pnpm? Get it [here](https://pnpm.io/installation)
|
||||
2. Setup Node.JS with nvm:
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Setup Node version with nvm">
|
||||
|
||||
```bash
|
||||
nvm install && nvm use
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
3. Install Node.JS packages via pnpm. Don't have pnpm? Get it [here](https://pnpm.io/installation)
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Install dependencies via pnpm">
|
||||
@@ -79,7 +91,7 @@ pnpm install
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
3. Create a `.env` file based on `.env.example`. It's already preset to work with the local development setup but you can also change values if needed.
|
||||
4. Create a `.env` file based on `.env.example`. It's already preset to work with the local development setup but you can also change values if needed.
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Define environment variables">
|
||||
@@ -91,7 +103,7 @@ cp .env.example .env
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
4. Generate & set some secret values mandatory for the `ENCRYPTION_KEY`, `NEXTAUTH_SECRET` and `CRON_SECRET` in the .env file. You can use the following command to generate the random string of required length:
|
||||
5. Generate & set some secret values mandatory for the `ENCRYPTION_KEY`, `NEXTAUTH_SECRET` and `CRON_SECRET` in the .env file. You can use the following command to generate the random string of required length:
|
||||
|
||||
- For Linux
|
||||
|
||||
@@ -121,7 +133,7 @@ sed -i '' '/^CRON_SECRET=/s|.*|CRON_SECRET='$(openssl rand -hex 32)'|' .env
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
5. Make sure you have [`Docker`](https://docs.docker.com/compose/) & [`docker-compose`](https://docs.docker.com/compose/) installed and running on your machine. Then run the following command to start the Formbricks dev setup:
|
||||
6. Make sure you have [`Docker`](https://docs.docker.com/compose/) & [`docker-compose`](https://docs.docker.com/compose/) installed and running on your machine. Then run the following command to start the Formbricks dev setup:
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Start Formbricks Dev Setup">
|
||||
|
||||
@@ -48,7 +48,7 @@ Initialize the Formbricks JS Client for surveys. When used in a web app, pass a
|
||||
<CodeGroup title="Initialize Formbricks">
|
||||
|
||||
```javascript
|
||||
import formbricks from "@formbricks/js/app";
|
||||
import formbricks from "@formbricks/js";
|
||||
|
||||
formbricks.init({
|
||||
environmentId: "<your-environment-id>", // required
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,45 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import SurveyEmbed from "@/components/SurveyEmbed";
|
||||
import AddImageOrVideoToQuestionImage from "./images/add-image-or-video-to-question-image.webp";
|
||||
import AddImageOrVideoToQuestionVideo from "./images/add-image-or-video-to-question-video.webp";
|
||||
import AddImageOrVideoToQuestion from "./images/add-image-or-video-to-question.webp";
|
||||
|
||||
#### Add Image or Video to a Question
|
||||
|
||||
Enhance your questions by adding images or videos. This makes instructions clearer and the survey more engaging.
|
||||
|
||||
## How to Add Images
|
||||
|
||||
Click the icon on the right side of the question to add an image or video:
|
||||
|
||||
<MdxImage
|
||||
src={AddImageOrVideoToQuestion}
|
||||
alt="Overview of adding image or video to question"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
Upload an image by clicking the upload icon or dragging the file:
|
||||
|
||||
<MdxImage
|
||||
src={AddImageOrVideoToQuestionImage}
|
||||
alt="Overview of adding image to question"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
## How to Add Videos
|
||||
Toggle to add a video via link:
|
||||
|
||||
<MdxImage
|
||||
src={AddImageOrVideoToQuestionVideo}
|
||||
alt="Overview of adding video to question"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### Supported Video Platforms
|
||||
|
||||
We support YouTube, Vimeo, and Loom URLs.
|
||||
|
||||
<Note>**YouTube Privacy Mode**: This option reduces tracking by converting YouTube URLs to no-cookie URLs. It only works with YouTube.</Note>
|
||||
@@ -48,7 +48,7 @@ We have step-by-step guides to configure our third-party integrations with a sel
|
||||
<Note>
|
||||
{" "}
|
||||
Once you’ve configured your integration, See our Integration sections to see how to use them within your Formbricks
|
||||
app [here](/how-to-formbricks/integrations)
|
||||
app [here](/developer-docs/integrations/airtable)
|
||||
</Note>
|
||||
|
||||
### Step by Step Guides
|
||||
@@ -102,12 +102,11 @@ Enabling the Airtable Integration in a self-hosted environment requires creating
|
||||
/>
|
||||
|
||||
5. Click on the "Save" button and you are done
|
||||
6. Now just copy **Client ID** and **Redirect URL** for your integration & add it to your **Formbricks environment variables** as in the docker compose file:
|
||||
6. Now just copy **Client ID** for your integration & add it to your **Formbricks environment variables** as in the docker compose file:
|
||||
|
||||
- `AIRTABLE_CLIENT_ID`
|
||||
- `AIRTABLE_REDIRECT_URL`
|
||||
|
||||
Voila! You have successfully enabled the Airtable integration in your self-hosted Formbricks instance. Now you can follow the steps mentioned in [Airtable Integration with Formbricks](/integrations#airtable) section to link an Airtable with Formbricks.
|
||||
Voila! You have successfully enabled the Airtable integration in your self-hosted Formbricks instance. Now you can follow the steps mentioned in [Airtable Integration with Formbricks](/developer-docs/integrations/airtable) section to link an Airtable with Formbricks.
|
||||
|
||||
## Google Sheets
|
||||
|
||||
@@ -152,7 +151,7 @@ Now just copy **GOOGLE_SHEETS_CLIENT_ID**, **GOOGLE_SHEETS_CLIENT_SECRET** and *
|
||||
- `GOOGLE_SHEETS_CLIENT_SECRET`
|
||||
- `GOOGLE_SHEETS_REDIRECT_URL`
|
||||
|
||||
Voila! You have successfully enabled the Google Sheets integration in your self-hosted Formbricks instance. Now you can follow the steps mentioned in [Google Sheets Integration with Formbricks](/integrations#google-sheets) section to link a Google Sheet with Formbricks.
|
||||
Voila! You have successfully enabled the Google Sheets integration in your self-hosted Formbricks instance. Now you can follow the steps mentioned in [Google Sheets Integration with Formbricks](/developer-docs/integrations/google-sheets) section to link a Google Sheet with Formbricks.
|
||||
|
||||
## Notion:
|
||||
|
||||
@@ -170,7 +169,7 @@ Enabling the Notion Integration in a self-hosted environment requires a setup us
|
||||
- `NOTION_OAUTH_CLIENT_ID` - OAuth Client ID
|
||||
- `NOTION_OAUTH_CLIENT_SECRET` - OAuth Client Secret
|
||||
|
||||
Voila! You have successfully enabled the Notion integration in your self-hosted Formbricks instance. Now you can follow the steps mentioned in [Notion Integration with Formbricks](/integrations#notion) section to link your Notion with Formbricks.
|
||||
Voila! You have successfully enabled the Notion integration in your self-hosted Formbricks instance. Now you can follow the steps mentioned in [Notion Integration with Formbricks](/developer-docs/integrations/notion) section to link your Notion with Formbricks.
|
||||
|
||||
## n8n
|
||||
|
||||
@@ -289,7 +288,7 @@ Once the execution is successful, you'll receive the content in the discord chan
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
Voila! You have successfully enabled the n8n integration in your self-hosted Formbricks instance. Now you can follow the steps mentioned in the [Formbricks](/integrations#n8n) Integrations section to know more about the capabilities with Formbricks with n8n.
|
||||
Voila! You have successfully enabled the n8n integration in your self-hosted Formbricks instance. Now you can follow the steps mentioned in the [Formbricks](/developer-docs/integrations/n8n) Integrations section to know more about the capabilities with Formbricks with n8n.
|
||||
|
||||
## Slack
|
||||
|
||||
@@ -321,7 +320,7 @@ Enabling the Slack Integration in a self-hosted environment requires a setup usi
|
||||
8. Now, you need to enable the public distribution of your app. Go to the **Basic Information** tab and click on the **Manage distribution** button and click on the "Distribute App".
|
||||
9. Scroll down to the **Share your app with other workspaces** section, complete the checklist and click on the **Activate public distribution** button.
|
||||
|
||||
Voila! You have successfully enabled the Slack integration in your self-hosted Formbricks instance. Now you can follow the steps mentioned in the [Slack Integration](/integrations#slack) section to link a Slack workspace with Formbricks.
|
||||
Voila! You have successfully enabled the Slack integration in your self-hosted Formbricks instance. Now you can follow the steps mentioned in the [Slack Integration](/developer-docs/integrations/slack) section to link a Slack workspace with Formbricks.
|
||||
|
||||
## Zapier
|
||||
|
||||
@@ -348,7 +347,7 @@ Then, choose the event you want to trigger the Zap on:
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
Now you need an API key. Please refer to the [API Key Setup](/developer-docs/rest-api#api-key) page to learn how to create one.
|
||||
Now you need an API key. Please refer to the [API Key Setup](/developer-docs/rest-api##how-to-generate-an-api-key) page to learn how to create one.
|
||||
|
||||
Once you copied it in the newly opened Zapier window, you will be connected:
|
||||
|
||||
@@ -359,6 +358,6 @@ Once you copied it in the newly opened Zapier window, you will be connected:
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
Voila! You have successfully configured Zapier to work with your self-hosted Formbricks instance. Now you can follow the steps mentioned in the [Zapier Integration](/integrations#zapier) section to connect it with your Formbricks app and see it live.
|
||||
Voila! You have successfully configured Zapier to work with your self-hosted Formbricks instance. Now you can follow the steps mentioned in the [Zapier Integration](/developer-docs/integrations/zapier) section to connect it with your Formbricks app and see it live.
|
||||
|
||||
---
|
||||
|
||||
@@ -160,6 +160,33 @@ const NavigationGroup = ({
|
||||
const pathname = usePathname();
|
||||
const [isActiveGroup, setIsActiveGroup] = useState<boolean>(false);
|
||||
|
||||
// We need to expand the group with the current link so we loop over all links
|
||||
// Until we find the one and then expand the groups
|
||||
useEffect(() => {
|
||||
const findMatchingGroup = () => {
|
||||
for (const group of navigation) {
|
||||
for (const link of group.links) {
|
||||
if (!link.children) continue;
|
||||
|
||||
const matchingChild = link.children.find((child) => pathname && child.href.startsWith(pathname));
|
||||
|
||||
if (matchingChild) {
|
||||
setOpenGroups([`${group.title}-${link.title}`]);
|
||||
setActiveGroup(group);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
findMatchingGroup();
|
||||
|
||||
return () => {
|
||||
setOpenGroups([]);
|
||||
setActiveGroup(null);
|
||||
};
|
||||
}, [pathname, setActiveGroup, setOpenGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsActiveGroup(activeGroup?.title === group.title);
|
||||
}, [activeGroup?.title, group.title]);
|
||||
|
||||
@@ -9,7 +9,7 @@ export const TwitterIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}>
|
||||
<path d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z"></path>
|
||||
<path d="M403.229 0h78.506L310.219 196.04 512 462.799H354.002L230.261 301.007 88.669 462.799h-78.56l183.455-209.683L0 0h161.999l111.856 147.88L403.229 0zm-27.556 415.805h43.505L138.363 44.527h-46.68l283.99 371.278z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -49,6 +49,10 @@ export const navigation: Array<NavGroup> = [
|
||||
{ title: "Verify Email before Survey", href: "/link-surveys/verify-email-before-survey" },
|
||||
{ title: "PIN Protected Surveys", href: "/link-surveys/pin-protected-surveys" },
|
||||
{ title: "Partial Submissions", href: "/global/partial-submissions" },
|
||||
{
|
||||
title: "Add Image/Video to Question",
|
||||
href: "/global/add-image-or-video-question",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -76,6 +80,10 @@ export const navigation: Array<NavGroup> = [
|
||||
{ title: "Recall Functionality", href: "/global/recall" }, // global
|
||||
{ title: "Partial Submissions", href: "/global/partial-submissions" }, // global
|
||||
{ title: "Shareable Dashboards", href: "/global/shareable-dashboards" },
|
||||
{
|
||||
title: "Add Image/Video to Question",
|
||||
href: "/global/add-image-or-video-question",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
+3
-1
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
@@ -22,6 +23,7 @@ interface AddQuestionButtonProps {
|
||||
export const AddQuestionButton = ({ addQuestion, product, isCxMode }: AddQuestionButtonProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [hoveredQuestionId, setHoveredQuestionId] = useState<string | null>(null);
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
const availableQuestionTypes = isCxMode ? CXQuestionTypes : questionTypes;
|
||||
|
||||
@@ -44,7 +46,7 @@ export const AddQuestionButton = ({ addQuestion, product, isCxMode }: AddQuestio
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="justify-left flex flex-col">
|
||||
<Collapsible.CollapsibleContent className="justify-left flex flex-col" ref={parent}>
|
||||
{/* <hr className="py-1 text-slate-600" /> */}
|
||||
{availableQuestionTypes.map((questionType) => (
|
||||
<button
|
||||
|
||||
+3
-1
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
@@ -91,6 +92,7 @@ export const AddressQuestionForm = ({
|
||||
question.country,
|
||||
]);
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
@@ -106,7 +108,7 @@ export const AddressQuestionForm = ({
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div ref={parent}>
|
||||
{question.subheader !== undefined && (
|
||||
<div className="inline-flex w-full items-center">
|
||||
<div className="w-full">
|
||||
|
||||
+11
@@ -40,6 +40,17 @@ export const AnimatedSurveyBg = ({ handleBgChange, background }: AnimatedSurveyB
|
||||
"/animated-bgs/Thumbnails/28_Thumb.mp4": "/animated-bgs/4K/28_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/29_Thumb.mp4": "/animated-bgs/4K/29_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/30_Thumb.mp4": "/animated-bgs/4K/30_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/31_Thumb.mp4": "/animated-bgs/4K/31_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/32_Thumb.mp4": "/animated-bgs/4K/32_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/33_Thumb.mp4": "/animated-bgs/4K/33_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/34_Thumb.mp4": "/animated-bgs/4K/34_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/35_Thumb.mp4": "/animated-bgs/4K/35_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/36_Thumb.mp4": "/animated-bgs/4K/36_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/37_Thumb.mp4": "/animated-bgs/4K/37_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/38_Thumb.mp4": "/animated-bgs/4K/38_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/39_Thumb.mp4": "/animated-bgs/4K/39_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/40_Thumb.mp4": "/animated-bgs/4K/40_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/41_Thumb.mp4": "/animated-bgs/4K/41_4k.mp4",
|
||||
};
|
||||
|
||||
const togglePlayback = (index: number, type: "play" | "pause") => {
|
||||
|
||||
+4
-1
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
@@ -32,6 +33,8 @@ export const BackgroundStylingCard = ({
|
||||
isUnsplashConfigured,
|
||||
form,
|
||||
}: BackgroundStylingCardProps) => {
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
@@ -72,7 +75,7 @@ export const BackgroundStylingCard = ({
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent>
|
||||
<Collapsible.CollapsibleContent className="flex flex-col" ref={parent}>
|
||||
<hr className="pt-1 text-slate-600" />
|
||||
<div className="flex flex-col gap-6 p-6 pt-2">
|
||||
<FormField
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useState } from "react";
|
||||
import { LocalizedEditor } from "@formbricks/ee/multi-language/components/localized-editor";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
@@ -41,9 +42,9 @@ export const CTAQuestionForm = ({
|
||||
attributeClasses,
|
||||
}: CTAQuestionFormProps): JSX.Element => {
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
return (
|
||||
<form>
|
||||
<form ref={parent}>
|
||||
<QuestionFormInput
|
||||
id="headline"
|
||||
value={question.headline}
|
||||
|
||||
+3
-1
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
@@ -42,6 +43,7 @@ export const CardStylingSettings = ({
|
||||
const appCardArrangement = form.watch("cardArrangement.appSurveys") ?? "simple";
|
||||
const roundness = form.watch("roundness") ?? 8;
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
return (
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
@@ -78,7 +80,7 @@ export const CardStylingSettings = ({
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
|
||||
<Collapsible.CollapsibleContent>
|
||||
<Collapsible.CollapsibleContent className="flex flex-col" ref={parent}>
|
||||
<hr className="py-1 text-slate-600" />
|
||||
|
||||
<div className="flex flex-col gap-6 p-6 pt-2">
|
||||
|
||||
+4
-2
@@ -3,6 +3,7 @@ import {
|
||||
getDefaultOperatorForQuestion,
|
||||
replaceEndingCardHeadlineRecall,
|
||||
} from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
@@ -111,16 +112,17 @@ export function ConditionalLogic({
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="mt-4" ref={parent}>
|
||||
<Label className="flex gap-2">
|
||||
Conditional Logic
|
||||
<SplitIcon className="h-4 w-4 rotate-90" />
|
||||
</Label>
|
||||
|
||||
{question.logic && question.logic.length > 0 && (
|
||||
<div className="mt-2 flex flex-col gap-4">
|
||||
<div className="mt-2 flex flex-col gap-4" ref={parent}>
|
||||
{question.logic.map((logicItem, logicItemIdx) => (
|
||||
<div
|
||||
key={logicItem.id}
|
||||
|
||||
+5
-1
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
@@ -78,6 +79,9 @@ export const ContactInfoQuestionForm = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [question.firstName, question.lastName, question.email, question.phone, question.company]);
|
||||
|
||||
// Auto animate
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
@@ -93,7 +97,7 @@ export const ContactInfoQuestionForm = ({
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div ref={parent}>
|
||||
{question.subheader !== undefined && (
|
||||
<div className="inline-flex w-full items-center">
|
||||
<div className="w-full">
|
||||
|
||||
+3
-2
@@ -1,3 +1,4 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
@@ -45,7 +46,7 @@ export const DateQuestionForm = ({
|
||||
attributeClasses,
|
||||
}: IDateQuestionFormProps): JSX.Element => {
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
@@ -60,7 +61,7 @@ export const DateQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
<div>
|
||||
<div ref={parent}>
|
||||
{question.subheader !== undefined && (
|
||||
<div className="mt-2 inline-flex w-full items-center">
|
||||
<div className="w-full">
|
||||
|
||||
+1
-1
@@ -203,7 +203,7 @@ export const EditEndingCard = ({
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="mt-3 px-4 pb-6">
|
||||
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${open && "mt-3 pb-6"}`}>
|
||||
<TooltipRenderer
|
||||
shouldRender={endingCard.type === "endScreen" && isRedirectToUrlDisabled}
|
||||
tooltipContent={"Redirect To Url is not available on free plan"}
|
||||
|
||||
+2
-2
@@ -72,7 +72,7 @@ export const EditWelcomeCard = ({
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className="flex-1 rounded-r-lg border border-slate-200 transition-all duration-300 ease-in-out">
|
||||
className="flex-1 rounded-r-lg border border-slate-200 transition-all duration-200 ease-in-out">
|
||||
<Collapsible.CollapsibleTrigger
|
||||
asChild
|
||||
className="flex cursor-pointer justify-between rounded-r-lg p-4 hover:bg-slate-50">
|
||||
@@ -102,7 +102,7 @@ export const EditWelcomeCard = ({
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="px-4 pb-6">
|
||||
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${open && "pb-6"}`}>
|
||||
<form>
|
||||
<div className="mt-2">
|
||||
<Label htmlFor="companyLogo">Company Logo</Label>
|
||||
|
||||
+4
-1
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { PlusIcon, XCircleIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -121,6 +122,8 @@ export const FileUploadQuestionForm = ({
|
||||
updateQuestion(questionIdx, { maxSizeInMB: checked ? defaultMaxSizeInMB : undefined });
|
||||
};
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
@@ -135,7 +138,7 @@ export const FileUploadQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
<div>
|
||||
<div ref={parent}>
|
||||
{question.subheader !== undefined && (
|
||||
<div className="inline-flex w-full items-center">
|
||||
<div className="w-full">
|
||||
|
||||
+4
-1
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { CheckIcon, SparklesIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
@@ -67,6 +68,8 @@ export const FormStylingSettings = ({
|
||||
}
|
||||
};
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
@@ -103,7 +106,7 @@ export const FormStylingSettings = ({
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
|
||||
<Collapsible.CollapsibleContent>
|
||||
<Collapsible.CollapsibleContent className="flex flex-col" ref={parent}>
|
||||
<hr className="py-1 text-slate-600" />
|
||||
|
||||
<div className="flex flex-col gap-6 p-6 pt-2">
|
||||
|
||||
+6
-2
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { findHiddenFieldUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { EyeOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -85,6 +86,9 @@ export const HiddenFieldsCard = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Auto Animate
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<div className={cn(open ? "shadow-lg" : "shadow-md", "group z-10 flex flex-row rounded-lg bg-white")}>
|
||||
<div
|
||||
@@ -124,8 +128,8 @@ export const HiddenFieldsCard = ({
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="px-4 pb-6">
|
||||
<div className="flex gap-2">
|
||||
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${open && "pb-6"}`} ref={parent}>
|
||||
<div className="flex flex-wrap gap-2" ref={parent}>
|
||||
{localSurvey.hiddenFields?.fieldIds && localSurvey.hiddenFields?.fieldIds?.length > 0 ? (
|
||||
localSurvey.hiddenFields?.fieldIds?.map((fieldId) => {
|
||||
return (
|
||||
|
||||
+4
-1
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { AlertCircleIcon, CheckIcon, LinkIcon, MonitorIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -89,6 +90,8 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowT
|
||||
},
|
||||
];
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
@@ -114,7 +117,7 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowT
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent>
|
||||
<Collapsible.CollapsibleContent className="flex flex-col" ref={parent}>
|
||||
<hr className="py-1 text-slate-600" />
|
||||
<div className="p-3">
|
||||
<RadioGroup
|
||||
|
||||
+4
-1
@@ -4,6 +4,7 @@ import {
|
||||
getDefaultOperatorForQuestion,
|
||||
getMatchValueProps,
|
||||
} from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { CopyIcon, EllipsisVerticalIcon, PlusIcon, TrashIcon, WorkflowIcon } from "lucide-react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
@@ -52,6 +53,8 @@ export function LogicEditorConditions({
|
||||
updateQuestion,
|
||||
depth = 0,
|
||||
}: LogicEditorConditionsProps) {
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
const handleAddConditionBelow = (resourceId: string) => {
|
||||
const operator = getDefaultOperatorForQuestion(question);
|
||||
|
||||
@@ -334,7 +337,7 @@ export function LogicEditorConditions({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div ref={parent} className="flex flex-col gap-y-2">
|
||||
{conditions?.conditions.map((condition, index) => renderCondition(condition, index, conditions))}
|
||||
</div>
|
||||
);
|
||||
|
||||
+6
-4
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
@@ -96,7 +97,8 @@ export const MatrixQuestionForm = ({
|
||||
show: true,
|
||||
},
|
||||
};
|
||||
|
||||
/// Auto animate
|
||||
const [parent] = useAutoAnimate();
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
@@ -111,7 +113,7 @@ export const MatrixQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
<div>
|
||||
<div ref={parent}>
|
||||
{question.subheader !== undefined && (
|
||||
<div className="inline-flex w-full items-center">
|
||||
<div className="w-full">
|
||||
@@ -150,7 +152,7 @@ export const MatrixQuestionForm = ({
|
||||
<div>
|
||||
{/* Rows section */}
|
||||
<Label htmlFor="rows">Rows</Label>
|
||||
<div>
|
||||
<div ref={parent}>
|
||||
{question.rows.map((_, index) => (
|
||||
<div className="flex items-center" onKeyDown={(e) => handleKeyDown(e, "row")}>
|
||||
<QuestionFormInput
|
||||
@@ -192,7 +194,7 @@ export const MatrixQuestionForm = ({
|
||||
<div>
|
||||
{/* Columns section */}
|
||||
<Label htmlFor="columns">Columns</Label>
|
||||
<div>
|
||||
<div ref={parent}>
|
||||
{question.columns.map((_, index) => (
|
||||
<div className="flex items-center" onKeyDown={(e) => handleKeyDown(e, "column")}>
|
||||
<QuestionFormInput
|
||||
|
||||
+5
-2
@@ -3,6 +3,7 @@
|
||||
import { findOptionUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { DndContext } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -158,6 +159,8 @@ export const MultipleChoiceQuestionForm = ({
|
||||
}
|
||||
}, [isNew]);
|
||||
|
||||
// Auto animate
|
||||
const [parent] = useAutoAnimate();
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
@@ -173,7 +176,7 @@ export const MultipleChoiceQuestionForm = ({
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div ref={parent}>
|
||||
{question.subheader !== undefined && (
|
||||
<div className="inline-flex w-full items-center">
|
||||
<div className="w-full">
|
||||
@@ -236,7 +239,7 @@ export const MultipleChoiceQuestionForm = ({
|
||||
updateQuestion(questionIdx, { choices: newChoices });
|
||||
}}>
|
||||
<SortableContext items={question.choices} strategy={verticalListSortingStrategy}>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col" ref={parent}>
|
||||
{question.choices &&
|
||||
question.choices.map((choice, choiceIdx) => (
|
||||
<QuestionOptionChoice
|
||||
|
||||
+5
-1
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
@@ -32,6 +33,9 @@ export const NPSQuestionForm = ({
|
||||
attributeClasses,
|
||||
}: NPSQuestionFormProps): JSX.Element => {
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
// Auto animate
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
@@ -47,7 +51,7 @@ export const NPSQuestionForm = ({
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div ref={parent}>
|
||||
{question.subheader !== undefined && (
|
||||
<div className="mt-2 inline-flex w-full items-center">
|
||||
<div className="w-full">
|
||||
|
||||
+4
-1
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { HashIcon, LinkIcon, MailIcon, MessageSquareTextIcon, PhoneIcon, PlusIcon } from "lucide-react";
|
||||
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
@@ -54,6 +55,8 @@ export const OpenQuestionForm = ({
|
||||
updateQuestion(questionIdx, updatedAttributes);
|
||||
};
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
@@ -69,7 +72,7 @@ export const OpenQuestionForm = ({
|
||||
label={"Question*"}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div ref={parent}>
|
||||
{question.subheader !== undefined && (
|
||||
<div className="inline-flex w-full items-center">
|
||||
<div className="w-full">
|
||||
|
||||
+4
-2
@@ -1,3 +1,4 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
@@ -64,7 +65,8 @@ export const PictureSelectionForm = ({
|
||||
choices: updatedChoices,
|
||||
});
|
||||
};
|
||||
|
||||
// Auto animate
|
||||
const [parent] = useAutoAnimate();
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
@@ -79,7 +81,7 @@ export const PictureSelectionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
<div>
|
||||
<div ref={parent}>
|
||||
{question.subheader !== undefined && (
|
||||
<div className="mt-2 inline-flex w-full items-center">
|
||||
<div className="w-full">
|
||||
|
||||
+26
-7
@@ -5,6 +5,7 @@ import { RankingQuestionForm } from "@/app/(app)/(survey-editor)/environments/[e
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -85,12 +86,18 @@ export const QuestionCard = ({
|
||||
|
||||
const open = activeQuestionId === question.id;
|
||||
const [openAdvanced, setOpenAdvanced] = useState(question.logic && question.logic.length > 0);
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
const updateEmptyNextButtonLabels = (labelValue: TI18nString) => {
|
||||
const updateEmptyButtonLabels = (
|
||||
labelKey: "buttonLabel" | "backButtonLabel",
|
||||
labelValue: TI18nString,
|
||||
skipIndex: number
|
||||
) => {
|
||||
localSurvey.questions.forEach((q, index) => {
|
||||
if (index === localSurvey.questions.length - 1) return;
|
||||
if (!q.buttonLabel || q.buttonLabel[selectedLanguageCode]?.trim() === "") {
|
||||
updateQuestion(index, { buttonLabel: labelValue });
|
||||
if (index === skipIndex) return;
|
||||
const currentLabel = q[labelKey];
|
||||
if (!currentLabel || currentLabel[selectedLanguageCode]?.trim() === "") {
|
||||
updateQuestion(index, { [labelKey]: labelValue });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -224,7 +231,7 @@ export const QuestionCard = ({
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="px-4 pb-4">
|
||||
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${open && "pb-4"}`}>
|
||||
{question.type === TSurveyQuestionTypeEnum.OpenText ? (
|
||||
<OpenQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
@@ -418,7 +425,7 @@ export const QuestionCard = ({
|
||||
{openAdvanced ? "Hide Advanced Settings" : "Show Advanced Settings"}
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
|
||||
<Collapsible.CollapsibleContent className="space-y-4">
|
||||
<Collapsible.CollapsibleContent className="flex flex-col gap-4" ref={parent}>
|
||||
{question.type !== TSurveyQuestionTypeEnum.NPS &&
|
||||
question.type !== TSurveyQuestionTypeEnum.Rating &&
|
||||
question.type !== TSurveyQuestionTypeEnum.CTA ? (
|
||||
@@ -444,7 +451,11 @@ export const QuestionCard = ({
|
||||
};
|
||||
|
||||
if (questionIdx === localSurvey.questions.length - 1) return;
|
||||
updateEmptyNextButtonLabels(translatedNextButtonLabel);
|
||||
updateEmptyButtonLabels(
|
||||
"buttonLabel",
|
||||
translatedNextButtonLabel,
|
||||
localSurvey.questions.length - 1
|
||||
);
|
||||
}}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
@@ -463,6 +474,14 @@ export const QuestionCard = ({
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
onBlur={(e) => {
|
||||
if (!question.backButtonLabel) return;
|
||||
let translatedBackButtonLabel = {
|
||||
...question.backButtonLabel,
|
||||
[selectedLanguageCode]: e.target.value,
|
||||
};
|
||||
updateEmptyButtonLabels("backButtonLabel", translatedBackButtonLabel, 0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+4
-1
@@ -1,4 +1,5 @@
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
@@ -41,8 +42,10 @@ export const QuestionsDroppable = ({
|
||||
isFormbricksCloud,
|
||||
isCxMode,
|
||||
}: QuestionsDraggableProps) => {
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<div className="group mb-5 flex w-full flex-col gap-5">
|
||||
<div className="group mb-5 flex w-full flex-col gap-5" ref={parent}>
|
||||
<SortableContext items={localSurvey.questions} strategy={verticalListSortingStrategy}>
|
||||
{localSurvey.questions.map((question, questionIdx) => (
|
||||
<QuestionCard
|
||||
|
||||
+6
-22
@@ -12,8 +12,9 @@ import {
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import React, { SetStateAction, useEffect, useMemo, useState } from "react";
|
||||
import React, { SetStateAction, useEffect, useMemo } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { MultiLanguageCard } from "@formbricks/ee/multi-language/components/multi-language-card";
|
||||
import { addMultiLanguageLabels, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
@@ -86,7 +87,6 @@ export const QuestionsView = ({
|
||||
}, [localSurvey.questions]);
|
||||
|
||||
const surveyLanguages = localSurvey.languages;
|
||||
const [backButtonLabel, setbackButtonLabel] = useState(null);
|
||||
|
||||
const handleQuestionLogicChange = (survey: TSurvey, compareId: string, updatedId: string): TSurvey => {
|
||||
const updateConditions = (conditions: TConditionGroup): TConditionGroup => {
|
||||
@@ -238,22 +238,6 @@ export const QuestionsView = ({
|
||||
...updatedAttributes,
|
||||
};
|
||||
|
||||
if ("backButtonLabel" in updatedAttributes) {
|
||||
const backButtonLabel = updatedSurvey.questions[questionIdx].backButtonLabel;
|
||||
// If the value of backbuttonLabel is equal to {default:""}, then delete backButtonLabel key
|
||||
if (
|
||||
backButtonLabel &&
|
||||
Object.keys(backButtonLabel).length === 1 &&
|
||||
backButtonLabel["default"].trim() === ""
|
||||
) {
|
||||
delete updatedSurvey.questions[questionIdx].backButtonLabel;
|
||||
} else {
|
||||
updatedSurvey.questions.forEach((question) => {
|
||||
question.backButtonLabel = updatedAttributes.backButtonLabel;
|
||||
});
|
||||
setbackButtonLabel(updatedAttributes.backButtonLabel);
|
||||
}
|
||||
}
|
||||
const attributesToCheck = ["buttonLabel", "upperLabel", "lowerLabel"];
|
||||
|
||||
// If the value of buttonLabel, lowerLabel or upperLabel is equal to {default:""}, then delete buttonLabel key
|
||||
@@ -332,9 +316,6 @@ export const QuestionsView = ({
|
||||
|
||||
const addQuestion = (question: TSurveyQuestion, index?: number) => {
|
||||
const updatedSurvey = { ...localSurvey };
|
||||
if (backButtonLabel) {
|
||||
question.backButtonLabel = backButtonLabel;
|
||||
}
|
||||
|
||||
const languageSymbols = extractLanguageCodes(localSurvey.languages);
|
||||
const updatedQuestion = addMultiLanguageLabels(question, languageSymbols);
|
||||
@@ -430,6 +411,9 @@ export const QuestionsView = ({
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
// Auto animate
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<div className="mt-12 w-full px-5 py-4">
|
||||
{!isCxMode && (
|
||||
@@ -473,7 +457,7 @@ export const QuestionsView = ({
|
||||
</DndContext>
|
||||
|
||||
<AddQuestionButton addQuestion={addQuestion} product={product} isCxMode={isCxMode} />
|
||||
<div className="mt-5 flex flex-col gap-5">
|
||||
<div className="mt-5 flex flex-col gap-5" ref={parent}>
|
||||
<hr className="border-t border-dashed" />
|
||||
<DndContext
|
||||
id="endings"
|
||||
|
||||
+5
-2
@@ -2,6 +2,7 @@
|
||||
|
||||
import { DndContext } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -107,6 +108,8 @@ export const RankingQuestionForm = ({
|
||||
}
|
||||
}, [question.choices?.length]);
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
@@ -122,7 +125,7 @@ export const RankingQuestionForm = ({
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div ref={parent}>
|
||||
{question.subheader !== undefined && (
|
||||
<div className="inline-flex w-full items-center">
|
||||
<div className="w-full">
|
||||
@@ -181,7 +184,7 @@ export const RankingQuestionForm = ({
|
||||
updateQuestion(questionIdx, { choices: newChoices });
|
||||
}}>
|
||||
<SortableContext items={question.choices} strategy={verticalListSortingStrategy}>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col" ref={parent}>
|
||||
{question.choices &&
|
||||
question.choices.map((choice, choiceIdx) => (
|
||||
<QuestionOptionChoice
|
||||
|
||||
+3
-2
@@ -1,3 +1,4 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { HashIcon, PlusIcon, SmileIcon, StarIcon } from "lucide-react";
|
||||
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
@@ -31,7 +32,7 @@ export const RatingQuestionForm = ({
|
||||
attributeClasses,
|
||||
}: RatingQuestionFormProps) => {
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
@@ -47,7 +48,7 @@ export const RatingQuestionForm = ({
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div ref={parent}>
|
||||
{question.subheader !== undefined && (
|
||||
<div className="mt-2 inline-flex w-full items-center">
|
||||
<div className="w-full">
|
||||
|
||||
+5
-1
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -57,6 +58,9 @@ export const RecontactOptionsCard = ({
|
||||
);
|
||||
const [displayLimit, setDisplayLimit] = useState(localSurvey.displayLimit ?? 1);
|
||||
|
||||
// Auto animate
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
const handleCheckMark = () => {
|
||||
if (ignoreWaiting) {
|
||||
const updatedSurvey = { ...localSurvey, recontactDays: null };
|
||||
@@ -119,7 +123,7 @@ export const RecontactOptionsCard = ({
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="pb-3">
|
||||
<Collapsible.CollapsibleContent className={`flex flex-col ${open && "pb-3"}`} ref={parent}>
|
||||
<hr className="py-1 text-slate-600" />
|
||||
<div className="p-3">
|
||||
<RadioGroup
|
||||
|
||||
+3
-1
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { ArrowUpRight, CheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -272,6 +273,7 @@ export const ResponseOptionsCard = ({
|
||||
return;
|
||||
}
|
||||
};
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<Collapsible.Root
|
||||
@@ -295,7 +297,7 @@ export const ResponseOptionsCard = ({
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent>
|
||||
<Collapsible.CollapsibleContent className="flex flex-col" ref={parent}>
|
||||
<hr className="py-1 text-slate-600" />
|
||||
<div className="p-3">
|
||||
{/* Close Survey on Limit */}
|
||||
|
||||
+3
-2
@@ -1,3 +1,4 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TabBar } from "@formbricks/ui/components/TabBar";
|
||||
import { AnimatedSurveyBg } from "./AnimatedSurveyBg";
|
||||
@@ -30,7 +31,7 @@ export const SurveyBgSelectorTab = ({
|
||||
isUnsplashConfigured,
|
||||
}: SurveyBgSelectorTabProps) => {
|
||||
const [activeTab, setActiveTab] = useState(bgType || "color");
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
const [colorBackground, setColorBackground] = useState(bg);
|
||||
const [animationBackground, setAnimationBackground] = useState(bg);
|
||||
const [uploadBackground, setUploadBackground] = useState(bg);
|
||||
@@ -93,7 +94,7 @@ export const SurveyBgSelectorTab = ({
|
||||
tabStyle="button"
|
||||
className="bg-slate-100"
|
||||
/>
|
||||
<div className="w-full rounded-b-lg border-x border-b border-slate-200 px-4 pb-4 pt-2">
|
||||
<div className="w-full rounded-b-lg border-x border-b border-slate-200 px-4 pb-4 pt-2" ref={parent}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ export const SurveyMenuBar = ({
|
||||
const [isConfirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
const [isSurveyPublishing, setIsSurveyPublishing] = useState(false);
|
||||
const [isSurveySaving, setIsSurveySaving] = useState(false);
|
||||
const cautionText = "This survey received responses.";
|
||||
const cautionText = "Changes will lead to inconsistencies.";
|
||||
|
||||
useEffect(() => {
|
||||
if (audiencePrompt && activeId === "settings") {
|
||||
|
||||
+4
-1
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -70,6 +71,8 @@ export const SurveyPlacementCard = ({
|
||||
}
|
||||
};
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
@@ -95,7 +98,7 @@ export const SurveyPlacementCard = ({
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="pb-3">
|
||||
<Collapsible.CollapsibleContent className={`flex ${open && "pb-3"}`} ref={parent}>
|
||||
<hr className="py-1 text-slate-600" />
|
||||
<div className="p-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
+4
-2
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { FileDigitIcon } from "lucide-react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
@@ -22,6 +23,7 @@ export const SurveyVariablesCard = ({
|
||||
setActiveQuestionId,
|
||||
}: SurveyVariablesCardProps) => {
|
||||
const open = activeQuestionId === variablesCardId;
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
const setOpenState = (state: boolean) => {
|
||||
if (state) {
|
||||
@@ -57,8 +59,8 @@ export const SurveyVariablesCard = ({
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="px-4 pb-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${open && "pb-6"}`} ref={parent}>
|
||||
<div className="flex flex-col gap-2" ref={parent}>
|
||||
{localSurvey.variables.length > 0 ? (
|
||||
localSurvey.variables.map((variable) => (
|
||||
<SurveyVariablesCardItem
|
||||
|
||||
+3
-1
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { AlertCircle, CheckIcon, ChevronDownIcon, ChevronUpIcon, PencilIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -154,6 +155,7 @@ export const TargetingCard = ({
|
||||
() => (localSurvey?.segment ? localSurvey.segment?.surveys?.length > 1 : false),
|
||||
[localSurvey.segment]
|
||||
);
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<Collapsible.Root
|
||||
@@ -176,7 +178,7 @@ export const TargetingCard = ({
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="min-w-full overflow-auto">
|
||||
<Collapsible.CollapsibleContent className="flex min-w-full flex-col overflow-auto" ref={parent}>
|
||||
<hr className="text-slate-600" />
|
||||
|
||||
<div className="flex flex-col gap-5 p-6">
|
||||
|
||||
+7
@@ -110,6 +110,13 @@ const defaultImages = [
|
||||
regularWithAttribution: "/image-backgrounds/kittens.webp",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "windows",
|
||||
alt_description: "Windows",
|
||||
urls: {
|
||||
regularWithAttribution: "/image-backgrounds/windows.webp",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const ImageFromUnsplashSurveyBg = ({ handleBgChange }: ImageFromUnsplashSurveyBgProps) => {
|
||||
|
||||
+5
-1
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import {
|
||||
CheckIcon,
|
||||
@@ -126,6 +127,9 @@ export const WhenToSendCard = ({
|
||||
}
|
||||
}, [localSurvey.type]);
|
||||
|
||||
// Auto animate
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
const containsEmptyTriggers = useMemo(() => {
|
||||
return !localSurvey.triggers || !localSurvey.triggers.length || !localSurvey.triggers[0];
|
||||
}, [localSurvey]);
|
||||
@@ -167,7 +171,7 @@ export const WhenToSendCard = ({
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
|
||||
<Collapsible.CollapsibleContent>
|
||||
<Collapsible.CollapsibleContent className="flex flex-col" ref={parent}>
|
||||
<hr className="py-1 text-slate-600" />
|
||||
|
||||
<div className="px-3 pb-3 pt-1">
|
||||
|
||||
+4
-1
@@ -11,6 +11,7 @@ import {
|
||||
} from "@dnd-kit/core";
|
||||
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
|
||||
import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { VisibilityState, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -54,6 +55,8 @@ export const PersonTable = ({
|
||||
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const router = useRouter();
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
// Generate columns
|
||||
const columns = useMemo(
|
||||
() => generatePersonTableColumns(isExpanded ?? false, searchValue),
|
||||
@@ -189,7 +192,7 @@ export const PersonTable = ({
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
<TableBody ref={parent}>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
|
||||
-1
@@ -43,7 +43,6 @@ export const BasicCreateSegmentModal = ({
|
||||
const [addFilterModalOpen, setAddFilterModalOpen] = useState(false);
|
||||
const [segment, setSegment] = useState<TSegment>(initialSegmentState);
|
||||
const [isCreatingSegment, setIsCreatingSegment] = useState(false);
|
||||
|
||||
const handleResetState = () => {
|
||||
setSegment(initialSegmentState);
|
||||
setOpen(false);
|
||||
|
||||
+6
@@ -1,6 +1,7 @@
|
||||
import { triggers } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/HardcodedTriggers";
|
||||
import { SurveyCheckboxGroup } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/SurveyCheckboxGroup";
|
||||
import { TriggerCheckboxGroup } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/TriggerCheckboxGroup";
|
||||
import { validWebHookURL } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/lib/utils";
|
||||
import clsx from "clsx";
|
||||
import { Webhook } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -42,6 +43,11 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
||||
|
||||
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
|
||||
try {
|
||||
const { valid, error } = validWebHookURL(testEndpointInput);
|
||||
if (!valid) {
|
||||
toast.error(error ?? "Something went wrong please try again!");
|
||||
return;
|
||||
}
|
||||
setHittingEndpoint(true);
|
||||
await testEndpointAction({ url: testEndpointInput });
|
||||
setHittingEndpoint(false);
|
||||
|
||||
+6
@@ -3,6 +3,7 @@
|
||||
import { triggers } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/HardcodedTriggers";
|
||||
import { SurveyCheckboxGroup } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/SurveyCheckboxGroup";
|
||||
import { TriggerCheckboxGroup } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/TriggerCheckboxGroup";
|
||||
import { validWebHookURL } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/lib/utils";
|
||||
import clsx from "clsx";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -46,6 +47,11 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen }: ActionSettings
|
||||
|
||||
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
|
||||
try {
|
||||
const { valid, error } = validWebHookURL(testEndpointInput);
|
||||
if (!valid) {
|
||||
toast.error(error ?? "Something went wrong please try again!");
|
||||
return;
|
||||
}
|
||||
setHittingEndpoint(true);
|
||||
await testEndpointAction({ url: testEndpointInput });
|
||||
setHittingEndpoint(false);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export const validWebHookURL = (urlInput: string) => {
|
||||
const trimmedInput = urlInput.trim();
|
||||
if (!trimmedInput) {
|
||||
return { valid: false, error: "Please enter a URL" };
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmedInput);
|
||||
|
||||
if (url.protocol !== "https:") {
|
||||
return { valid: false, error: "URL must start with https://" };
|
||||
}
|
||||
|
||||
const domainError: string =
|
||||
"Please enter a complete URL with a valid domain (e.g., https://formbricks.com)";
|
||||
|
||||
const multipleSlashesPattern = /(?<!:)\/\/+/;
|
||||
if (multipleSlashesPattern.test(trimmedInput)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: domainError,
|
||||
};
|
||||
}
|
||||
|
||||
const validDomainPattern = /^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||
if (!validDomainPattern.test(url.hostname)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: domainError,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
} catch (error) {
|
||||
return { valid: false, error: "Invalid URL format. Please enter a complete URL including https://" };
|
||||
}
|
||||
};
|
||||
+2
@@ -163,6 +163,8 @@ const EnterCode = ({ setCurrentStep, setOpen, refreshData }: TEnableCodeProps) =
|
||||
|
||||
// refresh data to update the UI
|
||||
refreshData();
|
||||
} else {
|
||||
toast.error("The 2FA OTP is incorrect. Please try again.");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
|
||||
+3
-1
@@ -13,6 +13,7 @@ import {
|
||||
} from "@dnd-kit/core";
|
||||
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
|
||||
import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { VisibilityState, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
@@ -65,6 +66,7 @@ export const ResponseTable = ({
|
||||
const selectedResponse = responses?.find((response) => response.id === selectedResponseId) ?? null;
|
||||
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
// Generate columns
|
||||
const columns = generateResponseTableColumns(survey, isExpanded ?? false, isViewer);
|
||||
@@ -199,7 +201,7 @@ export const ResponseTable = ({
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
<TableBody ref={parent}>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ export const CTASummary = ({ questionSummary, survey, attributeClasses }: CTASum
|
||||
<p className="font-semibold text-slate-700">CTR</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(questionSummary.ctr.percentage, 1)}%
|
||||
{convertFloatToNDecimal(questionSummary.ctr.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@ export const CalSummary = ({ questionSummary, survey, attributeClasses }: CalSum
|
||||
<p className="font-semibold text-slate-700">Booked</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(questionSummary.booked.percentage, 1)}%
|
||||
{convertFloatToNDecimal(questionSummary.booked.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,7 +42,7 @@ export const CalSummary = ({ questionSummary, survey, attributeClasses }: CalSum
|
||||
<p className="font-semibold text-slate-700">Dismissed</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(questionSummary.skipped.percentage, 1)}%
|
||||
{convertFloatToNDecimal(questionSummary.skipped.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ export const ConsentSummary = ({
|
||||
</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(summaryItem.percentage, 1)}%
|
||||
{convertFloatToNDecimal(summaryItem.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+27
-22
@@ -5,6 +5,7 @@ import { SparklesIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@formbricks/ui/components/Alert";
|
||||
import { Badge } from "@formbricks/ui/components/Badge";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
|
||||
interface EnableInsightsBannerProps {
|
||||
@@ -27,30 +28,34 @@ export const EnableInsightsBanner = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert className="w-1/2 bg-white">
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
<span>Ready to enable insights?</span>
|
||||
</AlertTitle>
|
||||
<AlertDescription className="flex items-start justify-between gap-4">
|
||||
<span>
|
||||
<Alert className="mb-6 mt-4 flex items-center gap-4 border-slate-400 bg-white">
|
||||
<div>
|
||||
<SparklesIcon strokeWidth={1.5} className="size-7 text-slate-700" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<AlertTitle>
|
||||
<span className="mr-2">Ready to test AI insights?</span>
|
||||
<Badge text="Beta" type="gray" size="normal" />
|
||||
</AlertTitle>
|
||||
<AlertDescription className="flex items-start justify-between gap-4">
|
||||
You can enable the new insights feature for the survey to get AI-based insights for your open-text
|
||||
responses.
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={handleInsightGeneration}
|
||||
disabled={surveyResponseCount > maxResponseCount || isGeneratingInsights}
|
||||
tooltip={
|
||||
surveyResponseCount > maxResponseCount
|
||||
? "Kindly contact us at hola@formbricks.com to generate insights for this survey"
|
||||
: undefined
|
||||
}>
|
||||
Enable Insights
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={handleInsightGeneration}
|
||||
loading={isGeneratingInsights}
|
||||
disabled={surveyResponseCount > maxResponseCount || isGeneratingInsights}
|
||||
tooltip={
|
||||
surveyResponseCount > maxResponseCount
|
||||
? "Kindly contact us at hola@formbricks.com to generate insights for this survey"
|
||||
: undefined
|
||||
}>
|
||||
Enable insights
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
+11
-2
@@ -1,3 +1,4 @@
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
|
||||
@@ -68,6 +69,14 @@ export const MultipleChoiceSummary = ({
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
attributeClasses={attributeClasses}
|
||||
additionalInfo={
|
||||
questionSummary.type === "multipleChoiceMulti" ? (
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
{`${questionSummary.selectionCount} Selections`}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{results.map((result, resultsIdx) => (
|
||||
@@ -92,12 +101,12 @@ export const MultipleChoiceSummary = ({
|
||||
</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 1)}%
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
||||
{result.count} {result.count === 1 ? "response" : "responses"}
|
||||
{result.count} {result.count === 1 ? "Selection" : "Selections"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="group-hover:opacity-80">
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ export const NPSSummary = ({ questionSummary, survey, attributeClasses, setFilte
|
||||
</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(questionSummary[group]?.percentage, 1)}%
|
||||
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+4
-4
@@ -102,7 +102,7 @@ export const OpenTextSummary = ({
|
||||
<TableBody>
|
||||
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||
<TableRow key={response.id}>
|
||||
<TableCell>
|
||||
<TableCell width={180}>
|
||||
{response.person ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
@@ -110,7 +110,7 @@ export const OpenTextSummary = ({
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.person.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
<p className="ph-no-capture break-normal text-slate-600 group-hover:underline md:ml-2">
|
||||
{getPersonIdentifier(response.person, response.personAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
@@ -119,12 +119,12 @@ export const OpenTextSummary = ({
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
|
||||
<p className="break-normal text-slate-600 md:ml-2">Anonymous</p>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{response.value}</TableCell>
|
||||
<TableCell>{timeSince(new Date(response.updatedAt).toISOString())}</TableCell>
|
||||
<TableCell width={120}>{timeSince(new Date(response.updatedAt).toISOString())}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
+11
-2
@@ -1,3 +1,4 @@
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import {
|
||||
@@ -38,6 +39,14 @@ export const PictureChoiceSummary = ({
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
attributeClasses={attributeClasses}
|
||||
additionalInfo={
|
||||
questionSummary.question.allowMulti ? (
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
{`${questionSummary.selectionCount} Selections`}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{results.map((result, index) => (
|
||||
@@ -66,12 +75,12 @@ export const PictureChoiceSummary = ({
|
||||
</div>
|
||||
<div className="self-end">
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 1)}%
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
||||
{result.count} {result.count === 1 ? "response" : "responses"}
|
||||
{result.count} {result.count === 1 ? "Selection" : "Selections"}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100 || 0} />
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ export const RankingSummary = ({
|
||||
<div className="rounded bg-gray-100 px-2 py-1">{result.value}</div>
|
||||
<span className="ml-auto flex items-center space-x-1">
|
||||
<span className="font-bold text-slate-600">
|
||||
#{convertFloatToNDecimal(result.avgRanking, 1)}
|
||||
#{convertFloatToNDecimal(result.avgRanking, 2)}
|
||||
</span>
|
||||
<span>average</span>
|
||||
</span>
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ export const RatingSummary = ({
|
||||
</div>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 1)}%
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+11
-2
@@ -1,5 +1,6 @@
|
||||
import { TimerIcon } from "lucide-react";
|
||||
import { TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { getQuestionIcon } from "@formbricks/lib/utils/questions";
|
||||
import { TSurveyQuestionType, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/components/Tooltip";
|
||||
|
||||
interface SummaryDropOffsProps {
|
||||
@@ -7,6 +8,11 @@ interface SummaryDropOffsProps {
|
||||
}
|
||||
|
||||
export const SummaryDropOffs = ({ dropOff }: SummaryDropOffsProps) => {
|
||||
const getIcon = (questionType: TSurveyQuestionType) => {
|
||||
const Icon = getQuestionIcon(questionType);
|
||||
return <Icon className="mt-[3px] h-5 w-5 shrink-0 text-slate-600" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="">
|
||||
@@ -31,7 +37,10 @@ export const SummaryDropOffs = ({ dropOff }: SummaryDropOffsProps) => {
|
||||
<div
|
||||
key={quesDropOff.questionId}
|
||||
className="grid grid-cols-6 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
|
||||
<div className="col-span-3 pl-4 md:pl-6">{quesDropOff.headline}</div>
|
||||
<div className="col-span-3 flex gap-3 pl-4 md:pl-6">
|
||||
{getIcon(quesDropOff.questionType)}
|
||||
<p>{quesDropOff.headline}</p>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-center font-semibold">
|
||||
{quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
|
||||
</div>
|
||||
|
||||
+4
-1
@@ -138,7 +138,10 @@ export const SurveyAnalysisCTA = ({
|
||||
)}
|
||||
|
||||
{!isViewer && (
|
||||
<Button href={`/environments/${environment.id}/surveys/${survey.id}/edit`} EndIcon={SquarePenIcon}>
|
||||
<Button
|
||||
href={`/environments/${environment.id}/surveys/${survey.id}/edit`}
|
||||
EndIcon={SquarePenIcon}
|
||||
size="base">
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
|
||||
+46
-21
@@ -235,6 +235,7 @@ export const getSurveySummaryDropOff = (
|
||||
const dropOff = survey.questions.map((question, index) => {
|
||||
return {
|
||||
questionId: question.id,
|
||||
questionType: question.type,
|
||||
headline: getLocalizedValue(question.headline, "default"),
|
||||
ttc: convertFloatTo2Decimal(totalTtc[question.id]) || 0,
|
||||
impressions: impressionsArr[index] || 0,
|
||||
@@ -318,7 +319,7 @@ export const getQuestionSummary = async (
|
||||
insightsEnabled: question.insightsEnabled,
|
||||
});
|
||||
|
||||
values;
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
@@ -339,6 +340,8 @@ export const getQuestionSummary = async (
|
||||
}, {});
|
||||
|
||||
const otherValues: TSurveyQuestionSummaryMultipleChoice["choices"][number]["others"] = [];
|
||||
let totalSelectionCount = 0;
|
||||
let totalResponseCount = 0;
|
||||
responses.forEach((response) => {
|
||||
const responseLanguageCode = getLanguageCode(survey.languages, response.language);
|
||||
|
||||
@@ -347,36 +350,51 @@ export const getQuestionSummary = async (
|
||||
? response.data[question.id]
|
||||
: checkForI18n(response, question.id, survey, responseLanguageCode);
|
||||
|
||||
let hasValidAnswer = false;
|
||||
|
||||
if (Array.isArray(answer)) {
|
||||
answer.forEach((value) => {
|
||||
if (questionChoices.includes(value)) {
|
||||
choiceCountMap[value]++;
|
||||
} else {
|
||||
if (value) {
|
||||
totalSelectionCount++;
|
||||
if (questionChoices.includes(value)) {
|
||||
choiceCountMap[value]++;
|
||||
} else if (isOthersEnabled) {
|
||||
otherValues.push({
|
||||
value,
|
||||
person: response.person,
|
||||
personAttributes: response.personAttributes,
|
||||
});
|
||||
}
|
||||
hasValidAnswer = true;
|
||||
}
|
||||
});
|
||||
} else if (typeof answer === "string") {
|
||||
if (answer) {
|
||||
totalSelectionCount++;
|
||||
if (questionChoices.includes(answer)) {
|
||||
choiceCountMap[answer]++;
|
||||
} else if (isOthersEnabled) {
|
||||
otherValues.push({
|
||||
value,
|
||||
value: answer,
|
||||
person: response.person,
|
||||
personAttributes: response.personAttributes,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (typeof answer === "string") {
|
||||
if (questionChoices.includes(answer)) {
|
||||
choiceCountMap[answer]++;
|
||||
} else {
|
||||
otherValues.push({
|
||||
value: answer,
|
||||
person: response.person,
|
||||
personAttributes: response.personAttributes,
|
||||
});
|
||||
hasValidAnswer = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasValidAnswer) {
|
||||
totalResponseCount++;
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(choiceCountMap).map(([label, count]) => {
|
||||
values.push({
|
||||
value: label,
|
||||
count,
|
||||
percentage: responses.length > 0 ? convertFloatTo2Decimal((count / responses.length) * 100) : 0,
|
||||
percentage:
|
||||
totalSelectionCount > 0 ? convertFloatTo2Decimal((count / totalSelectionCount) * 100) : 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -384,14 +402,18 @@ export const getQuestionSummary = async (
|
||||
values.push({
|
||||
value: getLocalizedValue(lastChoice.label, "default") || "Other",
|
||||
count: otherValues.length,
|
||||
percentage: convertFloatTo2Decimal((otherValues.length / responses.length) * 100),
|
||||
percentage:
|
||||
totalSelectionCount > 0
|
||||
? convertFloatTo2Decimal((otherValues.length / totalSelectionCount) * 100)
|
||||
: 0,
|
||||
others: otherValues.slice(0, VALUES_LIMIT),
|
||||
});
|
||||
}
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: responses.length,
|
||||
responseCount: totalResponseCount,
|
||||
selectionCount: totalSelectionCount,
|
||||
choices: values,
|
||||
});
|
||||
|
||||
@@ -406,12 +428,14 @@ export const getQuestionSummary = async (
|
||||
choiceCountMap[choice.id] = 0;
|
||||
});
|
||||
let totalResponseCount = 0;
|
||||
let totalSelectionCount = 0;
|
||||
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (Array.isArray(answer)) {
|
||||
totalResponseCount++;
|
||||
answer.forEach((value) => {
|
||||
totalResponseCount++;
|
||||
totalSelectionCount++;
|
||||
choiceCountMap[value]++;
|
||||
});
|
||||
}
|
||||
@@ -423,8 +447,8 @@ export const getQuestionSummary = async (
|
||||
imageUrl: choice.imageUrl,
|
||||
count: choiceCountMap[choice.id],
|
||||
percentage:
|
||||
totalResponseCount > 0
|
||||
? convertFloatTo2Decimal((choiceCountMap[choice.id] / totalResponseCount) * 100)
|
||||
totalSelectionCount > 0
|
||||
? convertFloatTo2Decimal((choiceCountMap[choice.id] / totalSelectionCount) * 100)
|
||||
: 0,
|
||||
});
|
||||
});
|
||||
@@ -433,6 +457,7 @@ export const getQuestionSummary = async (
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: totalResponseCount,
|
||||
selectionCount: totalSelectionCount,
|
||||
choices: values,
|
||||
});
|
||||
|
||||
|
||||
+62
-57
@@ -8,10 +8,11 @@ import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environment
|
||||
import { QuestionFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox";
|
||||
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
|
||||
import { getSurveyFilterDataBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import clsx from "clsx";
|
||||
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { Checkbox } from "@formbricks/ui/components/Checkbox";
|
||||
@@ -31,6 +32,7 @@ interface ResponseFilterProps {
|
||||
|
||||
export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
const params = useParams();
|
||||
const [parent] = useAutoAnimate();
|
||||
const sharingKey = params.sharingKey as string;
|
||||
const isSharingPage = !!sharingKey;
|
||||
|
||||
@@ -224,63 +226,66 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{filterValue.filter?.map((s, i) => (
|
||||
<>
|
||||
<div className="flex w-full flex-wrap gap-3 md:flex-nowrap">
|
||||
<div
|
||||
className="grid w-full grid-cols-1 items-center gap-3 md:grid-cols-2"
|
||||
key={`${s.questionType.id}-${i}`}>
|
||||
<QuestionsComboBox
|
||||
key={`${s.questionType.label}-${i}`}
|
||||
options={questionComboBoxOptions}
|
||||
selected={s.questionType}
|
||||
onChangeValue={(value) => handleOnChangeQuestionComboBoxValue(value, i)}
|
||||
/>
|
||||
<QuestionFilterComboBox
|
||||
key={`${s.questionType.id}-${i}`}
|
||||
filterOptions={
|
||||
selectedOptions.questionFilterOptions.find(
|
||||
(q) =>
|
||||
(q.type === s.questionType.questionType || q.type === s.questionType.type) &&
|
||||
q.id === s.questionType.id
|
||||
)?.filterOptions
|
||||
}
|
||||
filterComboBoxOptions={
|
||||
selectedOptions.questionFilterOptions.find(
|
||||
(q) =>
|
||||
(q.type === s.questionType.questionType || q.type === s.questionType.type) &&
|
||||
q.id === s.questionType.id
|
||||
)?.filterComboBoxOptions
|
||||
}
|
||||
filterValue={filterValue.filter[i].filterType.filterValue}
|
||||
filterComboBoxValue={filterValue.filter[i].filterType.filterComboBoxValue}
|
||||
type={
|
||||
s?.questionType?.type === OptionsType.QUESTIONS
|
||||
? s?.questionType?.questionType
|
||||
: s?.questionType?.type
|
||||
}
|
||||
handleRemoveMultiSelect={(value) => handleRemoveMultiSelect(value, i)}
|
||||
onChangeFilterComboBoxValue={(value) => handleOnChangeFilterComboBoxValue(value, i)}
|
||||
onChangeFilterValue={(value) => handleOnChangeFilterValue(value, i)}
|
||||
disabled={!s?.questionType?.label}
|
||||
/>
|
||||
|
||||
<div ref={parent}>
|
||||
{filterValue.filter?.map((s, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<div className="flex w-full flex-wrap gap-3 md:flex-nowrap">
|
||||
<div
|
||||
className="grid w-full grid-cols-1 items-center gap-3 md:grid-cols-2"
|
||||
key={`${s.questionType.id}-${i}`}>
|
||||
<QuestionsComboBox
|
||||
key={`${s.questionType.label}-${i}`}
|
||||
options={questionComboBoxOptions}
|
||||
selected={s.questionType}
|
||||
onChangeValue={(value) => handleOnChangeQuestionComboBoxValue(value, i)}
|
||||
/>
|
||||
<QuestionFilterComboBox
|
||||
key={`${s.questionType.id}-${i}`}
|
||||
filterOptions={
|
||||
selectedOptions.questionFilterOptions.find(
|
||||
(q) =>
|
||||
(q.type === s.questionType.questionType || q.type === s.questionType.type) &&
|
||||
q.id === s.questionType.id
|
||||
)?.filterOptions
|
||||
}
|
||||
filterComboBoxOptions={
|
||||
selectedOptions.questionFilterOptions.find(
|
||||
(q) =>
|
||||
(q.type === s.questionType.questionType || q.type === s.questionType.type) &&
|
||||
q.id === s.questionType.id
|
||||
)?.filterComboBoxOptions
|
||||
}
|
||||
filterValue={filterValue.filter[i].filterType.filterValue}
|
||||
filterComboBoxValue={filterValue.filter[i].filterType.filterComboBoxValue}
|
||||
type={
|
||||
s?.questionType?.type === OptionsType.QUESTIONS
|
||||
? s?.questionType?.questionType
|
||||
: s?.questionType?.type
|
||||
}
|
||||
handleRemoveMultiSelect={(value) => handleRemoveMultiSelect(value, i)}
|
||||
onChangeFilterComboBoxValue={(value) => handleOnChangeFilterComboBoxValue(value, i)}
|
||||
onChangeFilterValue={(value) => handleOnChangeFilterValue(value, i)}
|
||||
disabled={!s?.questionType?.label}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-end gap-1 md:w-auto">
|
||||
<p className="block font-light text-slate-500 md:hidden">Delete</p>
|
||||
<TrashIcon
|
||||
className="w-4 cursor-pointer text-slate-500 md:text-black"
|
||||
onClick={() => handleDeleteFilter(i)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-end gap-1 md:w-auto">
|
||||
<p className="block font-light text-slate-500 md:hidden">Delete</p>
|
||||
<TrashIcon
|
||||
className="w-4 cursor-pointer text-slate-500 md:text-black"
|
||||
onClick={() => handleDeleteFilter(i)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{i !== filterValue.filter.length - 1 && (
|
||||
<div className="my-6 flex items-center">
|
||||
<p className="mr-6 text-base text-slate-600">And</p>
|
||||
<hr className="w-full text-slate-600" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
{i !== filterValue.filter.length - 1 && (
|
||||
<div className="my-6 flex items-center">
|
||||
<p className="mr-6 text-base text-slate-600">And</p>
|
||||
<hr className="w-full text-slate-600" />
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<Button size="sm" variant="secondary" onClick={handleAddNewFilter}>
|
||||
Add filter
|
||||
|
||||
@@ -71,41 +71,40 @@ export const SurveyCard = ({
|
||||
}, [survey.status, survey.id, environment.id]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={linkHref}
|
||||
key={survey.id}
|
||||
className="relative grid w-full grid-cols-8 place-items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition-all ease-in-out hover:scale-[101%]">
|
||||
<div className="col-span-1 flex max-w-full items-center justify-self-start text-sm font-medium text-slate-900">
|
||||
<div className="w-full truncate">{survey.name}</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"col-span-1 flex w-fit items-center gap-2 whitespace-nowrap rounded-full py-1 pl-1 pr-2 text-sm text-slate-800",
|
||||
surveyStatusLabel === "Scheduled" && "bg-slate-200",
|
||||
surveyStatusLabel === "In Progress" && "bg-emerald-50",
|
||||
surveyStatusLabel === "Completed" && "bg-slate-200",
|
||||
surveyStatusLabel === "Draft" && "bg-slate-100",
|
||||
surveyStatusLabel === "Paused" && "bg-slate-100"
|
||||
)}>
|
||||
<SurveyStatusIndicator status={survey.status} /> {surveyStatusLabel}{" "}
|
||||
</div>
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
{survey.responseCount}
|
||||
</div>
|
||||
<div className="col-span-1 flex justify-between">
|
||||
<SurveyTypeIndicator type={survey.type} />
|
||||
</div>
|
||||
<Link href={linkHref} key={survey.id} className="relative block">
|
||||
<div className="grid w-full grid-cols-8 place-items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 pr-8 shadow-sm transition-colors ease-in-out hover:border-slate-400">
|
||||
<div className="col-span-2 flex max-w-full items-center justify-self-start text-sm font-medium text-slate-900">
|
||||
<div className="w-full truncate">{survey.name}</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"col-span-1 flex w-fit items-center gap-2 whitespace-nowrap rounded-full py-1 pl-1 pr-2 text-sm text-slate-800",
|
||||
surveyStatusLabel === "Scheduled" && "bg-slate-200",
|
||||
surveyStatusLabel === "In Progress" && "bg-emerald-50",
|
||||
surveyStatusLabel === "Completed" && "bg-slate-200",
|
||||
surveyStatusLabel === "Draft" && "bg-slate-100",
|
||||
surveyStatusLabel === "Paused" && "bg-slate-100"
|
||||
)}>
|
||||
<SurveyStatusIndicator status={survey.status} /> {surveyStatusLabel}{" "}
|
||||
</div>
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
{survey.responseCount}
|
||||
</div>
|
||||
<div className="col-span-1 flex justify-between">
|
||||
<SurveyTypeIndicator type={survey.type} />
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
{convertDateString(survey.createdAt.toString())}
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
{convertDateString(survey.createdAt.toString())}
|
||||
</div>
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
{timeSince(survey.updatedAt.toString())}
|
||||
</div>
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
{survey.creator ? survey.creator.name : "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
{timeSince(survey.updatedAt.toString())}
|
||||
</div>
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
{survey.creator ? survey.creator.name : "-"}
|
||||
</div>
|
||||
<div className="col-span-1 place-self-end">
|
||||
<div className="absolute right-3 top-3.5">
|
||||
<SurveyDropDownMenu
|
||||
survey={survey}
|
||||
key={`surveys-${survey.id}`}
|
||||
|
||||
+11
-4
@@ -3,11 +3,18 @@
|
||||
import {
|
||||
copySurveyToOtherEnvironmentAction,
|
||||
deleteSurveyAction,
|
||||
getSurveyAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/actions";
|
||||
import { getSurveyAction } from "@/app/(app)/environments/[environmentId]/surveys/actions";
|
||||
import { TSurvey } from "@/app/(app)/environments/[environmentId]/surveys/types/surveys";
|
||||
import { ArrowUpFromLineIcon, CopyIcon, EyeIcon, LinkIcon, SquarePenIcon, TrashIcon } from "lucide-react";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
import {
|
||||
ArrowUpFromLineIcon,
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
LinkIcon,
|
||||
MoreVertical,
|
||||
SquarePenIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -99,7 +106,7 @@ export const SurveyDropDownMenu = ({
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
|
||||
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
|
||||
<div className="rounded-lg border p-2 hover:bg-slate-50">
|
||||
<div className="rounded-lg border bg-white p-2 hover:bg-slate-50">
|
||||
<span className="sr-only">Open options</span>
|
||||
<MoreVertical className="h-4 w-4" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { getSurveysAction } from "@/app/(app)/environments/[environmentId]/surveys/actions";
|
||||
import { getFormattedFilters } from "@/app/(app)/environments/[environmentId]/surveys/lib/utils";
|
||||
import { TSurvey } from "@/app/(app)/environments/[environmentId]/surveys/types/surveys";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@formbricks/lib/localStorage";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
@@ -49,6 +50,7 @@ export const SurveysList = ({
|
||||
const [isFilterInitialized, setIsFilterInitialized] = useState(false);
|
||||
|
||||
const filters = useMemo(() => getFormattedFilters(surveyFilters, userId), [surveyFilters, userId]);
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -136,9 +138,9 @@ export const SurveysList = ({
|
||||
/>
|
||||
{surveys.length > 0 ? (
|
||||
<div>
|
||||
<div className="flex-col space-y-3">
|
||||
<div className="mt-6 grid w-full grid-cols-8 place-items-center gap-3 px-6 text-sm text-slate-800">
|
||||
<div className="col-span-1 place-self-start">Name</div>
|
||||
<div className="flex-col space-y-3" ref={parent}>
|
||||
<div className="mt-6 grid w-full grid-cols-8 place-items-center gap-3 px-6 pr-8 text-sm text-slate-800">
|
||||
<div className="col-span-2 place-self-start">Name</div>
|
||||
<div className="col-span-1">Status</div>
|
||||
<div className="col-span-1">Responses</div>
|
||||
<div className="col-span-1">Type</div>
|
||||
|
||||
+8
-7
@@ -2,20 +2,21 @@
|
||||
|
||||
export const SurveyLoading = () => {
|
||||
return (
|
||||
<div className="grid h-full w-full animate-pulse grid-cols-2 place-content-stretch gap-4 lg:grid-cols-3 2xl:grid-cols-5">
|
||||
<div className="grid h-full w-full animate-pulse place-content-stretch gap-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="relative col-span-1 flex h-44 flex-col justify-between rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition-all ease-in-out">
|
||||
className="relative flex h-16 flex-col justify-between rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition-all ease-in-out">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="h-4 w-24 rounded-xl bg-slate-300"></div>
|
||||
<div className="h-4 w-32 rounded-xl bg-slate-400"></div>
|
||||
<div className="h-4 w-20 rounded-xl bg-slate-200"></div>
|
||||
<div className="h-4 w-20 rounded-xl bg-slate-200"></div>
|
||||
<div className="h-4 w-20 rounded-xl bg-slate-200"></div>
|
||||
<div className="h-4 w-20 rounded-xl bg-slate-200"></div>
|
||||
<div className="h-4 w-20 rounded-xl bg-slate-200"></div>
|
||||
<div className="h-8 w-8 rounded-md bg-slate-300"></div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="h-4 w-24 rounded-xl bg-slate-400"></div>
|
||||
<div className="h-4 w-20 rounded-xl bg-slate-400"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -211,6 +211,7 @@ export const SigninForm = ({
|
||||
)}
|
||||
{emailAuthEnabled && (
|
||||
<Button
|
||||
size="base"
|
||||
onClick={() => {
|
||||
if (!showLogin) {
|
||||
setShowLogin(true);
|
||||
@@ -224,7 +225,7 @@ export const SigninForm = ({
|
||||
loading={loggingIn}>
|
||||
{totpLogin ? "Submit" : "Login with Email"}
|
||||
{lastLoggedInWith && lastLoggedInWith === "Email" ? (
|
||||
<span className="absolute right-3 text-xs">Last Used</span>
|
||||
<span className="absolute right-3 text-xs opacity-50">Last Used</span>
|
||||
) : null}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { attributeCache } from "@formbricks/lib/attribute/cache";
|
||||
import { getAttributesByUserId } from "@formbricks/lib/attribute/service";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { displayCache } from "@formbricks/lib/display/cache";
|
||||
@@ -71,7 +70,6 @@ export const getPersonState = async ({
|
||||
const personResponses = await getResponsesByUserId(environmentId, userId);
|
||||
const personDisplays = await getDisplaysByUserId(environmentId, userId);
|
||||
const segments = await getPersonSegmentIds(environmentId, person, device);
|
||||
const attributes = await getAttributesByUserId(environmentId, userId);
|
||||
|
||||
// If the person exists, return the persons's state
|
||||
const userState: TJsPersonState["data"] = {
|
||||
@@ -81,7 +79,6 @@ export const getPersonState = async ({
|
||||
personDisplays?.map((display) => ({ surveyId: display.surveyId, createdAt: display.createdAt })) ??
|
||||
[],
|
||||
responses: personResponses?.map((response) => response.surveyId) ?? [],
|
||||
attributes,
|
||||
lastDisplayAt:
|
||||
personDisplays.length > 0
|
||||
? personDisplays.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0].createdAt
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { sendForgotPasswordEmail } from "@formbricks/email";
|
||||
import { loginLimiter } from "@/app/middleware/bucket";
|
||||
|
||||
export const POST = async (request: Request) => {
|
||||
const { email } = await request.json();
|
||||
|
||||
try {
|
||||
await loginLimiter(request.headers.get("x-forwarded-for") || request.connection.remoteAddress);
|
||||
|
||||
const foundUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: email.toLowerCase(),
|
||||
|
||||
@@ -29,3 +29,8 @@ export const syncUserIdentificationLimiter = rateLimit({
|
||||
interval: SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
|
||||
allowedPerInterval: SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
|
||||
export const forgotPasswordLimiter = rateLimit({
|
||||
interval: LOGIN_RATE_LIMIT.interval,
|
||||
allowedPerInterval: LOGIN_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
|
||||
@@ -28,3 +28,5 @@ export const isSyncWithUserIdentificationEndpoint = (
|
||||
const match = url.match(regex);
|
||||
return match ? { environmentId: match[1], userId: match[2] } : false;
|
||||
};
|
||||
|
||||
export const forgotPasswordRoute = (url: string) => url === "/api/v1/users/forgot-password";
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
shareUrlLimiter,
|
||||
signUpLimiter,
|
||||
syncUserIdentificationLimiter,
|
||||
forgotPasswordLimiter,
|
||||
} from "@/app/middleware/bucket";
|
||||
import {
|
||||
clientSideApiRoute,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
loginRoute,
|
||||
shareUrlRoute,
|
||||
signupRoute,
|
||||
forgotPasswordRoute,
|
||||
} from "@/app/middleware/endpointValidator";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
import { NextResponse } from "next/server";
|
||||
@@ -60,6 +62,8 @@ export const middleware = async (request: NextRequest) => {
|
||||
}
|
||||
} else if (shareUrlRoute(request.nextUrl.pathname)) {
|
||||
await shareUrlLimiter(`share-${ip}`);
|
||||
} else if (forgotPasswordRoute(request.nextUrl.pathname)) {
|
||||
await forgotPasswordLimiter(`forgot-password-${ip}`);
|
||||
}
|
||||
return NextResponse.next();
|
||||
} catch (e) {
|
||||
@@ -83,5 +87,6 @@ export const config = {
|
||||
"/api/auth/signout",
|
||||
"/auth/login",
|
||||
"/api/packages/:path*",
|
||||
"/api/v1/users/forgot-password",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -101,7 +101,7 @@ export const InsightView = ({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isFetching ? null : insights.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableRow className="pointer-events-none">
|
||||
<TableCell colSpan={4} className="py-8 text-center">
|
||||
<p className="text-slate-500">
|
||||
No insights found. Collect more survey responses or enable insights for your existing
|
||||
@@ -110,7 +110,7 @@ export const InsightView = ({
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : localInsights.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableRow className="pointer-events-none">
|
||||
<TableCell colSpan={4} className="py-8 text-center">
|
||||
<p className="text-slate-500">No insights found for this filter.</p>
|
||||
</TableCell>
|
||||
@@ -119,7 +119,7 @@ export const InsightView = ({
|
||||
localInsights.slice(0, visibleInsights).map((insight) => (
|
||||
<TableRow
|
||||
key={insight.id}
|
||||
className="cursor-pointer hover:bg-slate-50"
|
||||
className="group cursor-pointer hover:bg-slate-50"
|
||||
onClick={() => {
|
||||
setCurrentInsight(insight);
|
||||
setIsInsightSheetOpen(true);
|
||||
@@ -128,7 +128,9 @@ export const InsightView = ({
|
||||
{insight._count.documentInsights} <UserIcon className="ml-2 h-4 w-4" />
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{insight.title}</TableCell>
|
||||
<TableCell>{insight.description}</TableCell>
|
||||
<TableCell className="underline-offset-2 group-hover:underline">
|
||||
{insight.description}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{insight.category === "complaint" ? (
|
||||
<Badge text="Complaint" type="error" size="tiny" />
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@formbricks/ui/components/ToggleGroup";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@formbricks/ui/components/Tabs";
|
||||
|
||||
interface DashboardProps {
|
||||
user: TUser;
|
||||
@@ -32,26 +32,29 @@ export const Dashboard = ({
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-4">
|
||||
<Greeting userName={user.name} />
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
<hr className="border-slate-200" />
|
||||
<Tabs
|
||||
value={statsPeriod}
|
||||
onValueChange={(value) => value && setStatsPeriod(value as TStatsPeriod)}>
|
||||
<ToggleGroupItem value="day" aria-label="Toggle day">
|
||||
Today
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="week" aria-label="Toggle week">
|
||||
This week
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="month" aria-label="Toggle month">
|
||||
This month
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="quarter" aria-label="Toggle quarter">
|
||||
This quarter
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="all" aria-label="Toggle all">
|
||||
All time
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
onValueChange={(value) => value && setStatsPeriod(value as TStatsPeriod)}
|
||||
className="flex justify-center">
|
||||
<TabsList>
|
||||
<TabsTrigger value="day" aria-label="Toggle day">
|
||||
Today
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="week" aria-label="Toggle week">
|
||||
This week
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="month" aria-label="Toggle month">
|
||||
This month
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="quarter" aria-label="Toggle quarter">
|
||||
This quarter
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="all" aria-label="Toggle all">
|
||||
All time
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<ExperiencePageStats statsFrom={statsFrom} environmentId={environment.id} />
|
||||
<InsightsCard
|
||||
statsFrom={statsFrom}
|
||||
|
||||
@@ -134,7 +134,7 @@ export const InsightView = ({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{insights.length === 0 && !isFetching ? (
|
||||
<TableRow>
|
||||
<TableRow className="pointer-events-none">
|
||||
<TableCell colSpan={4} className="py-8 text-center">
|
||||
<p className="text-slate-500">
|
||||
No insights found. Collect more survey responses or enable insights for your existing
|
||||
@@ -146,7 +146,7 @@ export const InsightView = ({
|
||||
insights.map((insight) => (
|
||||
<TableRow
|
||||
key={insight.id}
|
||||
className="cursor-pointer hover:bg-slate-50"
|
||||
className="group cursor-pointer hover:bg-slate-50"
|
||||
onClick={() => {
|
||||
setCurrentInsight(insight);
|
||||
setIsInsightSheetOpen(true);
|
||||
@@ -155,7 +155,9 @@ export const InsightView = ({
|
||||
{insight._count.documentInsights} <UserIcon className="ml-2 h-4 w-4" />
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{insight.title}</TableCell>
|
||||
<TableCell>{insight.description}</TableCell>
|
||||
<TableCell className="underline-offset-2 group-hover:underline">
|
||||
{insight.description}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{insight.category === "complaint" ? (
|
||||
<Badge text="Complaint" type="error" size="tiny" />
|
||||
|
||||
@@ -162,6 +162,11 @@ const nextConfig = {
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff",
|
||||
},
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value:
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-src 'self'; media-src 'self' https:; object-src 'none'; base-uri 'self'; form-action 'self';",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable
BIN
Binary file not shown.
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user