mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-19 11:11:05 -05:00
Merge branch 'main' into randomize-row-order-matrix-questions
This commit is contained in:
4
LICENSE
4
LICENSE
@@ -1,9 +1,9 @@
|
||||
Copyright (c) 2023 Formbricks GmbH
|
||||
Copyright (c) 2024 Formbricks GmbH
|
||||
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
- All content that resides under the "packages/ee/" directory of this repository, if that directory exists, is licensed under the license defined in "packages/ee/LICENSE".
|
||||
- All content that resides under the "packages/js/", "packages/errors/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages.
|
||||
- All content that resides under the "packages/js/", "packages/react-native/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages.
|
||||
- All third party components incorporated into the Formbricks Software are licensed under the original license provided by the owner of the applicable component.
|
||||
- Content outside of the above mentioned directories or restrictions above is available under the "AGPLv3" license as defined below.
|
||||
|
||||
|
||||
2
apps/demo/next-env.d.ts
vendored
2
apps/demo/next-env.d.ts
vendored
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
|
||||
@@ -60,7 +60,7 @@ const AppPage = ({}) => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-white px-12 py-6 dark:bg-slate-800">
|
||||
<div className="min-h-screen bg-white px-12 py-6 dark:bg-slate-800">
|
||||
<div className="flex flex-col justify-between md:flex-row">
|
||||
<div className="flex flex-col items-center gap-2 sm:flex-row">
|
||||
<SurveySwitch value="app" formbricks={formbricks} />
|
||||
@@ -117,7 +117,7 @@ const AppPage = ({}) => {
|
||||
</div>
|
||||
|
||||
<div className="md:grid md:grid-cols-3">
|
||||
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-800">
|
||||
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
|
||||
<h3 className="text-lg font-semibold dark:text-white">
|
||||
Reset person / pull data from Formbricks app
|
||||
</h3>
|
||||
|
||||
@@ -56,8 +56,8 @@ const AppPage = ({}) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-white px-12 py-6 dark:bg-slate-800">
|
||||
<div className="flex flex-col items-center justify-between md:flex-row">
|
||||
<div className="min-h-screen bg-white px-12 py-6 dark:bg-slate-800">
|
||||
<div className="flex flex-col justify-between md:flex-row">
|
||||
<div className="flex flex-col items-center gap-2 sm:flex-row">
|
||||
<SurveySwitch value="website" formbricks={formbricks} />
|
||||
<div>
|
||||
@@ -113,7 +113,7 @@ const AppPage = ({}) => {
|
||||
</div>
|
||||
|
||||
<div className="md:grid md:grid-cols-3">
|
||||
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-800">
|
||||
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
|
||||
<h3 className="text-lg font-semibold dark:text-white">
|
||||
Reset person / pull data from Formbricks app
|
||||
</h3>
|
||||
|
||||
@@ -4,25 +4,20 @@ import I1 from "./images/I1.webp";
|
||||
import I2 from "./images/I2.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Using Actions in Formbricks | Fine-tuning User Moments",
|
||||
title: "Using Actions in Formbricks",
|
||||
description:
|
||||
"Dive deep into how actions in Formbricks help products and organizations to engage users at precise moments in their journey. Discover the power of actions, from coding to no-code setups, to refine user targeting and generate richer, more detailed user insights.",
|
||||
};
|
||||
|
||||
#### App Surveys
|
||||
|
||||
# Actions & Targeting
|
||||
# Actions
|
||||
|
||||
Understanding user thoughts and feelings at critical moments in their journey is pivotal. To achieve this, Formbricks uses user-centric actions that trigger surveys at precisely the right time. Actions are essentially notifications sent from your application to Formbricks when predefined user activities occur, making it possible to gather insights during key interactions.
|
||||
|
||||
<Note>
|
||||
Ensure that you’ve **initialized Formbricks with a userId** to fully utilize this feature along with other
|
||||
app survey capabilities.
|
||||
</Note>
|
||||
Actions are predefined events within your app that prompt Formbricks to display a survey when triggered. These are detected by the Formbricks widget, which then presents the appropriate survey based on your predefined settings.
|
||||
|
||||
## **How Do Actions Work?**
|
||||
|
||||
Actions in Formbricks App Surveys are deeply integrated with user activities within your app. When a user performs a specified action, the Formbricks widget detects this activity and can present a survey to that specific user if the trigger conditions match of that survey, while also recording the event. This capability ensures that surveys are not only triggered at the right time but are also tailored to the user’s recent interactions within the app. You can set up these actions through a user-friendly No-Code interface within the Formbricks dashboard.
|
||||
Actions in Formbricks App Surveys are deeply integrated with user activities within your app. When a user performs a specified action, the Formbricks widget detects this activity and can present a survey to that specific user if the trigger conditions match for that survey. This capability ensures that surveys are triggered at the right time. You can set up these actions through a user-friendly No-Code interface within the Formbricks dashboard.
|
||||
|
||||
## **Why Are Actions Useful?**
|
||||
|
||||
@@ -30,8 +25,7 @@ Actions are invaluable for enhancing survey relevance and effectiveness:
|
||||
|
||||
- **Personalized Engagement**: Surveys triggered by user actions ensure content is highly relevant and engaging, matching each user’s current context.
|
||||
- **User Attributes**: By tying surveys to specific user attributes, such as activity levels or feature usage, you can customize the survey experience to reflect individual user profiles.
|
||||
- **User Segments**: Analyze action data to create detailed user segments, targeting specific groups with surveys that are pertinent to their behaviors or interactions within the app.
|
||||
- **User Targeting**: Precise targeting based on user actions and attributes ensures that surveys are shown only to users who meet certain criteria, enhancing the relevance and effectiveness of each survey.
|
||||
- **User Targeting**: Precise targeting based on user attributes ensures that surveys are shown only to users who meet certain criteria, enhancing the relevance and effectiveness of each survey.
|
||||
|
||||
## **Setting Up No-Code Actions**
|
||||
|
||||
@@ -127,5 +121,3 @@ return <button onClick={handleClick}>Click Me</button>;
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
This documentation frames actions around user interactions, emphasizing the connection between the user's activities and the survey experience. By leveraging user-centric actions, you can create highly targeted and timely surveys that resonate with users and yield valuable insights.
|
||||
|
||||
@@ -1,39 +1,18 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import { ResponsiveVideo } from "@/components/ResponsiveVideo";
|
||||
|
||||
import GermansGpt from "./germans-gpt.webp";
|
||||
import Hni from "./hni.webp";
|
||||
import PowerUsers from "./power-users.webp";
|
||||
import RideHailing from "./ride-hailing.webp";
|
||||
import UpsellMiro from "./upsell-miro.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Advanced Targeting for In-app Surveys | Formbricks",
|
||||
title: "Advanced Targeting for App Surveys | Formbricks",
|
||||
description:
|
||||
"Advanced Targeting allows you to show surveys to just the right group of people. You can target surveys based on user attributes, user events, and metadata. This helps you get more relevant feedback and make data-driven decisions.",
|
||||
"Advanced Targeting allows you to show surveys to just the right group of people. You can target surveys based on user attributes, metadata, and other segments. This helps you get more relevant feedback and make data-driven decisions.",
|
||||
};
|
||||
|
||||
#### App Surveys
|
||||
|
||||
# Advanced Targeting
|
||||
|
||||
<Note>
|
||||
Targeting based on actions is deprecated in Advanced Targeting and will be removed soon. We recommend using
|
||||
filters on user attributes to target the survey only to specific groups of users.
|
||||
</Note>
|
||||
|
||||
Advanced Targeting allows you to show surveys to the right group of people. You can target surveys based on user attributes, device type, and more instead of spraying and praying. This helps you get more relevant feedback and make data-driven decisions. All of this without writing a single line of code.
|
||||
|
||||
<ResponsiveVideo
|
||||
title="Formbricks Multi-language Surveys"
|
||||
src="https://www.youtube-nocookie.com/embed/0BQp6N4cXzU?si=KeBM7G7Ch1xtrsOm&controls=0"
|
||||
/>
|
||||
# How to setup Advanced Targeting
|
||||
|
||||
## How to setup Advanced Targeting
|
||||
|
||||
<Note>
|
||||
Advanced Targeting is available on the Pro plan!
|
||||
</Note>
|
||||
<Note>Advanced Targeting is only available on the Pro plan!</Note>
|
||||
|
||||
1. On the Formbricks dashboard, click on **People** tab from the top navigation bar.
|
||||
|
||||
@@ -41,7 +20,7 @@ Advanced Targeting allows you to show surveys to the right group of people. You
|
||||
|
||||
3. Give your segment a title & a description to help you remember what this segment is about.
|
||||
|
||||
4. Now click on the **Add Filter** button to add a filter. You can filter based on actions, user attributes, other segments, devices, and more.
|
||||
4. Now click on the **Add Filter** button to add a filter. You can filter based on user attributes, other segments, devices, and more.
|
||||
|
||||
5. To group a set of filters together, click on the Three Dots icon on the right side of the filter and click on **Create Group**.
|
||||
|
||||
@@ -50,32 +29,3 @@ Advanced Targeting allows you to show surveys to the right group of people. You
|
||||
7. Once you are happy with the segment, click on **Save Segment**.
|
||||
|
||||
8. Now, when you create a survey, you can select this segment to target your survey to.
|
||||
|
||||
## Examples:
|
||||
|
||||
1. Let's say you want to upsell to: Miro, Loom, Figma, Slack and Asana.
|
||||
|
||||
<MdxImage
|
||||
src={UpsellMiro}
|
||||
alt="Upselling Opportunity"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
2. Post-experience surveying for a ride hailing app where users who have taken more than 1 ride are shown a survey.
|
||||
|
||||
<MdxImage
|
||||
src={RideHailing}
|
||||
alt="Ride Hailing Targeting"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
3. Sneak Peak: How we at Formbricks automate inviting power users to chat with us
|
||||
|
||||
<MdxImage
|
||||
src={PowerUsers}
|
||||
alt="Automate inviting power users to chat with us at Formbricks"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
@@ -37,8 +37,9 @@ To display the Product-Market Fit survey in your app you want to proceed as foll
|
||||
3. Setup the user action to display survey at good point in time
|
||||
|
||||
<Note>
|
||||
## Formbricks Widget running?
|
||||
We assume that you have already installed the Formbricks Widget in your web app. It’s required to display messages and surveys in your app. If not, please follow the [Quick Start Guide (15mins).](/app-surveys/quickstart)
|
||||
## Formbricks Widget running? We assume that you have already installed the Formbricks Widget in your web
|
||||
app. It’s required to display messages and surveys in your app. If not, please follow the [Quick Start Guide
|
||||
(15mins).](/app-surveys/quickstart)
|
||||
</Note>
|
||||
|
||||
### 1. Create new PMF survey
|
||||
@@ -69,25 +70,12 @@ _Want to change the button color? You can do so in the product settings!_
|
||||
|
||||
Save, and move over to where the magic happens: The “Audience” tab.
|
||||
|
||||
### 3. Pre-segment your audience (coming soon)
|
||||
|
||||
<Note>
|
||||
## Filter by attribute coming soon We're working on pre-segmenting users by attributes. We will update this
|
||||
manual in the next days.
|
||||
</Note>
|
||||
### 3. Pre-segment your audience
|
||||
|
||||
To run this survey properly, you should pre-segment your user base. As touched upon earlier: if you ask every user you’ll get lots of opinions which are often misleading. You only want to gather feedback from people who invested the time to get to know and use your product:
|
||||
|
||||
**Filter by attribute**: You can keep the logic to decide if a user has (or has not) experienced value in your application. This makes most sense if you want to use historic usage data to decide if a user qualifies or not. Create your logic and if it applies, send an attribute to Formbricks by e.g. `formbricks.setAttribute("Loyalty", "Experienced Value");` Here is the full manual on how to [set attributes](/app-surveys/user-identification).
|
||||
|
||||
**Filter by actions (coming soon)**: Later, you can also segment users based on events tracked with Formbricks. However, this makes it impossible to use historic usage data (pre Formbricks usage). Here we will have a few options to achieve that:
|
||||
|
||||
- Check the time passed since sign-up (e.g. signed up 4 weeks ago)
|
||||
- User has performed a specific action a certain number of times or (e.g. created 5 reports)
|
||||
- User has performed a combination of actions (e.g. created a report **and** invited a organization member)
|
||||
|
||||
This way you make sure that you separate potentially misleading opinions from valuable insights.
|
||||
|
||||
### 4. Set up a trigger for the Product-Market Fit survey:
|
||||
|
||||
You need a trigger to display the survey but in this case, the filtering does all the work. It’s up to you to decide to display the survey after the user viewed a specific subpage (pageURL) or after clicking an element. Have a look at the [Actions manual](/app-surveys/actions/) if you are not sure how to set them up:
|
||||
|
||||
@@ -6,7 +6,7 @@ import ApiKeySecret from "./images/api-key-secret.webp";
|
||||
export const metadata = {
|
||||
title: "Formbricks API Overview: Public Client & Management API Breakdown",
|
||||
description:
|
||||
"Formbricks provides a powerful API to manage your surveys, responses, users, displays, actions, attributes & webhooks programmatically. Get a detailed understanding of Formbricks' dual API offerings: the unauthenticated Public Client API optimized for client-side tasks and the secured Management API for advanced account operations. Choose the perfect fit for your integration needs and ensure robust data handling",
|
||||
"Formbricks provides a powerful API to manage your surveys, responses, users, displays, attributes & webhooks programmatically. Get a detailed understanding of Formbricks' dual API offerings: the unauthenticated Public Client API optimized for client-side tasks and the secured Management API for advanced account operations. Choose the perfect fit for your integration needs and ensure robust data handling",
|
||||
};
|
||||
|
||||
#### API
|
||||
@@ -23,7 +23,6 @@ The [Public Client API](https://documenter.getpostman.com/view/11026000/2sA3Bq5X
|
||||
|
||||
We currently have the following Client API methods exposed and below is their documentation attached in Postman:
|
||||
|
||||
- [Actions API](https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh#b8f3a10e-1642-4d82-a629-fef0a8c6c86c) - Create actions for a Person
|
||||
- [Displays API](https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh#949272bf-daec-4d72-9b52-47af3d74a62c) - Mark Survey as Displayed or Update an existing Display by linking it with a Response for a Person
|
||||
- [People API](https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh#ee3d2188-4253-4bca-9238-6b76455805a9) - Create & Update a Person (e.g. attributes, email, userId, etc)
|
||||
- [Responses API](https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh#8c773032-536c-483c-a237-c7697347946e) - Create & Update a Response for a Survey
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
@@ -11,11 +11,9 @@ export const metadata = {
|
||||
|
||||
#### Website Surveys
|
||||
|
||||
# Actions & Targeting
|
||||
# Actions
|
||||
|
||||
For public-facing websites, landing pages, and pages without user authentication walls, actions serve as effective triggers for displaying surveys. This method is particularly suitable for engaging general audiences, where **individual user tracking is not required or feasible**.
|
||||
|
||||
Actions in this context are straightforward triggers based on interactions with your website, allowing you to capture feedback precisely when it's most relevant.
|
||||
Actions are triggers based on interactions with your website, allowing you to capture feedback precisely when it's most relevant.
|
||||
|
||||
<Note>
|
||||
These actions operate **independently** as website surveys do not involve user identification. If you have
|
||||
@@ -46,7 +44,7 @@ Formbricks provides an intuitive No-Code interface for configuring actions, enab
|
||||
src={StepOne}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
2. Now click on “Add Action”
|
||||
@@ -55,7 +53,7 @@ Formbricks provides an intuitive No-Code interface for configuring actions, enab
|
||||
src={StepTwo}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
Here are four types of No-Code actions you can set up:
|
||||
@@ -57,7 +57,7 @@ export const navigation: Array<NavGroup> = [
|
||||
{
|
||||
title: "Features",
|
||||
children: [
|
||||
{ title: "Actions & Targeting", href: "/website-surveys/actions-and-targeting" },
|
||||
{ title: "Actions", href: "/website-surveys/actions" },
|
||||
{ title: "Show Survey to % of users", href: "/global/show-survey-to-percent-of-users" }, // app and website
|
||||
{ title: "Recontact Options", href: "/app-surveys/recontact" },
|
||||
{ title: "Hidden Fields", href: "/global/hidden-fields" }, // global
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Dance from "@/images/onboarding-dance.gif";
|
||||
import Lost from "@/images/onboarding-lost.gif";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
@@ -62,20 +59,21 @@ export const ConnectWithFormbricks = ({
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-[30rem] w-1/2 flex-col items-center justify-center rounded-lg border bg-slate-200 text-center shadow",
|
||||
widgetSetupCompleted ? "border-green-500 bg-green-100" : ""
|
||||
"flex h-[30rem] w-1/2 flex-col items-center justify-center rounded-lg border text-center",
|
||||
widgetSetupCompleted ? "border-green-500 bg-green-100" : "border-slate-300 bg-slate-200"
|
||||
)}>
|
||||
{widgetSetupCompleted ? (
|
||||
<div>
|
||||
<Image src={Dance} alt="lost" height={250} />
|
||||
<p className="mt-6 text-xl font-bold">Connection successful ✅</p>
|
||||
<p className="text-3xl">Congrats!</p>
|
||||
<p className="pt-4 text-sm font-medium text-slate-600">Well done! We're connected.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Image src={Lost} alt="lost" height={250} />
|
||||
<p className="animate-pulse pt-4 text-sm font-semibold text-slate-700">
|
||||
Waiting for your signal...
|
||||
</p>
|
||||
<div className="flex animate-pulse flex-col items-center space-y-4">
|
||||
<span className="relative flex h-10 w-10">
|
||||
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-slate-400 opacity-75"></span>
|
||||
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
|
||||
</span>
|
||||
<p className="pt-4 text-sm font-medium text-slate-600">Waiting for your signal...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -102,7 +102,7 @@ export const InviteOrganizationMember = ({ organization, environmentId }: Invite
|
||||
<div className="flex w-full justify-end space-x-2">
|
||||
<Button
|
||||
id="onboarding-inapp-invite-have-a-look-first"
|
||||
className="font-normal text-slate-400"
|
||||
className="text-slate-400"
|
||||
variant="minimal"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -33,7 +33,7 @@ const Page = async ({ params }: InvitePageProps) => {
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center">
|
||||
<Header
|
||||
title="Who is your favorite engineer?"
|
||||
subtitle="Invite your tech-savvy co-worker to help with the setup 🤓"
|
||||
subtitle="Invite your tech-savvy co-worker to help with the setup."
|
||||
/>
|
||||
<div className="space-y-4 text-center">
|
||||
<p className="text-4xl font-medium text-slate-800"></p>
|
||||
|
||||
@@ -28,10 +28,7 @@ const Page = async ({ params }: ConnectPageProps) => {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col items-center justify-center py-10">
|
||||
<Header
|
||||
title={`Let's connect your product with Formbricks`}
|
||||
subtitle="It takes less than 4 minutes, pinky promise!"
|
||||
/>
|
||||
<Header title={`Let's connect your product with Formbricks`} subtitle="It takes less than 4 minutes." />
|
||||
<div className="space-y-4 text-center">
|
||||
<p className="text-4xl font-medium text-slate-800"></p>
|
||||
<p className="text-sm text-slate-500"></p>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyAddressQuestion } from "@formbricks/types/surveys/types";
|
||||
import { AdvancedOptionToggle } from "@formbricks/ui/components/AdvancedOptionToggle";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
import { QuestionToggleTable } from "@formbricks/ui/components/QuestionToggleTable";
|
||||
|
||||
interface AddressQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -32,6 +33,64 @@ export const AddressQuestionForm = ({
|
||||
}: AddressQuestionFormProps): JSX.Element => {
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages ?? []);
|
||||
|
||||
const fields = [
|
||||
{
|
||||
id: "addressLine1",
|
||||
label: "Address Line 1",
|
||||
...question.addressLine1,
|
||||
},
|
||||
{
|
||||
id: "addressLine2",
|
||||
label: "Address Line 2",
|
||||
...question.addressLine2,
|
||||
},
|
||||
{
|
||||
id: "city",
|
||||
label: "City",
|
||||
...question.city,
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: "State",
|
||||
...question.state,
|
||||
},
|
||||
{
|
||||
id: "zip",
|
||||
label: "Zip",
|
||||
...question.zip,
|
||||
},
|
||||
{
|
||||
id: "country",
|
||||
label: "Country",
|
||||
...question.country,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const allFieldsAreOptional = [
|
||||
question.addressLine1,
|
||||
question.addressLine2,
|
||||
question.city,
|
||||
question.state,
|
||||
question.zip,
|
||||
question.country,
|
||||
]
|
||||
.filter((field) => field.show)
|
||||
.every((field) => !field.required);
|
||||
|
||||
if (allFieldsAreOptional) {
|
||||
updateQuestion(questionIdx, { required: false });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
question.addressLine1,
|
||||
question.addressLine2,
|
||||
question.city,
|
||||
question.state,
|
||||
question.zip,
|
||||
question.country,
|
||||
]);
|
||||
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
@@ -81,73 +140,30 @@ export const AddressQuestionForm = ({
|
||||
Add Description
|
||||
</Button>
|
||||
)}
|
||||
<div className="mt-2 font-medium">Settings</div>
|
||||
<AdvancedOptionToggle
|
||||
isChecked={question.isAddressLine1Required}
|
||||
onToggle={() =>
|
||||
|
||||
<QuestionToggleTable
|
||||
type="address"
|
||||
fields={fields}
|
||||
onShowToggle={(field, show) => {
|
||||
updateQuestion(questionIdx, {
|
||||
isAddressLine1Required: !question.isAddressLine1Required,
|
||||
required: true,
|
||||
})
|
||||
}
|
||||
htmlId="isAddressRequired"
|
||||
title="Required: Address Line 1"
|
||||
description=""
|
||||
childBorder
|
||||
customContainerClass="p-0 mt-4"></AdvancedOptionToggle>
|
||||
<AdvancedOptionToggle
|
||||
isChecked={question.isAddressLine2Required}
|
||||
onToggle={() =>
|
||||
[field.id]: {
|
||||
show,
|
||||
required: field.required,
|
||||
},
|
||||
// when show changes, and the field is required, the question should be required
|
||||
...(show && field.required && { required: true }),
|
||||
});
|
||||
}}
|
||||
onRequiredToggle={(field, required) => {
|
||||
updateQuestion(questionIdx, {
|
||||
isAddressLine2Required: !question.isAddressLine2Required,
|
||||
[field.id]: {
|
||||
show: field.show,
|
||||
required,
|
||||
},
|
||||
required: true,
|
||||
})
|
||||
}
|
||||
htmlId="isAddressLine2Required"
|
||||
title="Required: Address Line 2"
|
||||
description=""
|
||||
childBorder
|
||||
customContainerClass="p-0 mt-4"></AdvancedOptionToggle>
|
||||
<AdvancedOptionToggle
|
||||
isChecked={question.isCityRequired}
|
||||
onToggle={() =>
|
||||
updateQuestion(questionIdx, { isCityRequired: !question.isCityRequired, required: true })
|
||||
}
|
||||
htmlId="isCityRequired"
|
||||
title="Required: City / Town"
|
||||
description=""
|
||||
childBorder
|
||||
customContainerClass="p-0 mt-4"></AdvancedOptionToggle>
|
||||
<AdvancedOptionToggle
|
||||
isChecked={question.isStateRequired}
|
||||
onToggle={() =>
|
||||
updateQuestion(questionIdx, { isStateRequired: !question.isStateRequired, required: true })
|
||||
}
|
||||
htmlId="isStateRequired"
|
||||
title="Required: State / Region"
|
||||
description=""
|
||||
childBorder
|
||||
customContainerClass="p-0 mt-4"></AdvancedOptionToggle>
|
||||
<AdvancedOptionToggle
|
||||
isChecked={question.isZipRequired}
|
||||
onToggle={() =>
|
||||
updateQuestion(questionIdx, { isZipRequired: !question.isZipRequired, required: true })
|
||||
}
|
||||
htmlId="isZipRequired"
|
||||
title="Required: ZIP / Post Code"
|
||||
description=""
|
||||
childBorder
|
||||
customContainerClass="p-0 mt-4"></AdvancedOptionToggle>
|
||||
<AdvancedOptionToggle
|
||||
isChecked={question.isCountryRequired}
|
||||
onToggle={() =>
|
||||
updateQuestion(questionIdx, { isCountryRequired: !question.isCountryRequired, required: true })
|
||||
}
|
||||
htmlId="iscountryRequired"
|
||||
title="Required: Country"
|
||||
description=""
|
||||
childBorder
|
||||
customContainerClass="p-0 mt-4"></AdvancedOptionToggle>
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyContactInfoQuestion } from "@formbricks/types/surveys/types";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
import { QuestionToggleTable } from "@formbricks/ui/components/QuestionToggleTable";
|
||||
|
||||
interface ContactInfoQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyContactInfoQuestion;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyContactInfoQuestion>) => void;
|
||||
lastQuestion: boolean;
|
||||
isInvalid: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
attributeClasses: TAttributeClass[];
|
||||
}
|
||||
|
||||
export const ContactInfoQuestionForm = ({
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
isInvalid,
|
||||
localSurvey,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode,
|
||||
attributeClasses,
|
||||
}: ContactInfoQuestionFormProps): JSX.Element => {
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages ?? []);
|
||||
|
||||
const fields = [
|
||||
{
|
||||
id: "firstName",
|
||||
label: "First Name",
|
||||
...question.firstName,
|
||||
},
|
||||
{
|
||||
id: "lastName",
|
||||
label: "Last Name",
|
||||
...question.lastName,
|
||||
},
|
||||
{
|
||||
id: "email",
|
||||
label: "Email",
|
||||
...question.email,
|
||||
},
|
||||
{
|
||||
id: "phone",
|
||||
label: "Phone",
|
||||
...question.phone,
|
||||
},
|
||||
{
|
||||
id: "company",
|
||||
label: "Company",
|
||||
...question.company,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const allFieldsAreOptional = [
|
||||
question.firstName,
|
||||
question.lastName,
|
||||
question.email,
|
||||
question.phone,
|
||||
question.company,
|
||||
]
|
||||
.filter((field) => field.show)
|
||||
.every((field) => !field.required);
|
||||
|
||||
if (allFieldsAreOptional) {
|
||||
updateQuestion(questionIdx, { required: false });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [question.firstName, question.lastName, question.email, question.phone, question.company]);
|
||||
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
id="headline"
|
||||
value={question.headline}
|
||||
label={"Question*"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
|
||||
<div>
|
||||
{question.subheader !== undefined && (
|
||||
<div className="inline-flex w-full items-center">
|
||||
<div className="w-full">
|
||||
<QuestionFormInput
|
||||
id="subheader"
|
||||
value={question.subheader}
|
||||
label={"Description"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{question.subheader === undefined && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="minimal"
|
||||
className="mt-4"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateQuestion(questionIdx, {
|
||||
subheader: createI18nString("", surveyLanguageCodes),
|
||||
});
|
||||
}}>
|
||||
<PlusIcon className="mr-1 h-4 w-4" />
|
||||
Add Description
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<QuestionToggleTable
|
||||
type="contact"
|
||||
fields={fields}
|
||||
onShowToggle={(field, show) => {
|
||||
updateQuestion(questionIdx, {
|
||||
[field.id]: {
|
||||
show,
|
||||
required: field.required,
|
||||
},
|
||||
// when show changes, and the field is required, the question should be required
|
||||
...(show && field.required && { required: true }),
|
||||
});
|
||||
}}
|
||||
onRequiredToggle={(field, required) => {
|
||||
updateQuestion(questionIdx, {
|
||||
[field.id]: {
|
||||
show: field.show,
|
||||
required,
|
||||
},
|
||||
required: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ContactInfoQuestionForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ContactInfoQuestionForm";
|
||||
import { RankingQuestionForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm";
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
@@ -94,16 +95,25 @@ export const QuestionCard = ({
|
||||
};
|
||||
|
||||
const getIsRequiredToggleDisabled = (): boolean => {
|
||||
if (question.type === "address") {
|
||||
if (question.type === TSurveyQuestionTypeEnum.Address) {
|
||||
return [
|
||||
question.isAddressLine1Required,
|
||||
question.isAddressLine2Required,
|
||||
question.isCityRequired,
|
||||
question.isCountryRequired,
|
||||
question.isStateRequired,
|
||||
question.isZipRequired,
|
||||
].some((condition) => condition === true);
|
||||
question.addressLine1,
|
||||
question.addressLine2,
|
||||
question.city,
|
||||
question.state,
|
||||
question.zip,
|
||||
question.country,
|
||||
]
|
||||
.filter((field) => field.show)
|
||||
.some((condition) => condition.required === true);
|
||||
}
|
||||
|
||||
if (question.type === TSurveyQuestionTypeEnum.ContactInfo) {
|
||||
return [question.firstName, question.lastName, question.email, question.phone, question.company]
|
||||
.filter((field) => field.show)
|
||||
.some((condition) => condition.required === true);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -383,6 +393,18 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.ContactInfo ? (
|
||||
<ContactInfoQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-4">
|
||||
<Collapsible.Root open={openAdvanced} onOpenChange={setOpenAdvanced} className="mt-5">
|
||||
|
||||
@@ -73,7 +73,6 @@ export const SettingsView = ({
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
environmentId={environment.id}
|
||||
attributeClasses={attributeClasses}
|
||||
actionClasses={actionClasses}
|
||||
segments={segments}
|
||||
initialSegment={segments.find((segment) => segment.id === localSurvey.segment?.id)}
|
||||
/>
|
||||
|
||||
@@ -132,101 +132,97 @@ export const SurveyEditor = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<SurveyMenuBar
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
localSurvey={localSurvey}
|
||||
survey={survey}
|
||||
environment={environment}
|
||||
activeId={activeView}
|
||||
setActiveId={setActiveView}
|
||||
setInvalidQuestions={setInvalidQuestions}
|
||||
product={localProduct}
|
||||
responseCount={responseCount}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
<div className="relative z-0 flex flex-1 overflow-hidden">
|
||||
<main
|
||||
className="relative z-0 w-1/2 flex-1 overflow-y-auto bg-slate-50 focus:outline-none"
|
||||
ref={surveyEditorRef}>
|
||||
<QuestionsAudienceTabs
|
||||
activeId={activeView}
|
||||
setActiveId={setActiveView}
|
||||
isCxMode={isCxMode}
|
||||
isStylingTabVisible={!!product.styling.allowStyleOverwrite}
|
||||
/>
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<SurveyMenuBar
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
localSurvey={localSurvey}
|
||||
survey={survey}
|
||||
environment={environment}
|
||||
activeId={activeView}
|
||||
setActiveId={setActiveView}
|
||||
setInvalidQuestions={setInvalidQuestions}
|
||||
product={localProduct}
|
||||
responseCount={responseCount}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
<div className="relative z-0 flex flex-1 overflow-hidden">
|
||||
<main
|
||||
className="relative z-0 w-1/2 flex-1 overflow-y-auto bg-slate-50 focus:outline-none"
|
||||
ref={surveyEditorRef}>
|
||||
<QuestionsAudienceTabs
|
||||
activeId={activeView}
|
||||
setActiveId={setActiveView}
|
||||
isCxMode={isCxMode}
|
||||
isStylingTabVisible={!!product.styling.allowStyleOverwrite}
|
||||
/>
|
||||
|
||||
{activeView === "questions" && (
|
||||
<QuestionsView
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
product={localProduct}
|
||||
invalidQuestions={invalidQuestions}
|
||||
setInvalidQuestions={setInvalidQuestions}
|
||||
selectedLanguageCode={selectedLanguageCode ? selectedLanguageCode : "default"}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isMultiLanguageAllowed={isMultiLanguageAllowed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
attributeClasses={attributeClasses}
|
||||
plan={plan}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeView === "styling" && product.styling.allowStyleOverwrite && (
|
||||
<StylingView
|
||||
colors={colors}
|
||||
environment={environment}
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
product={localProduct}
|
||||
styling={styling ?? null}
|
||||
setStyling={setStyling}
|
||||
localStylingChanges={localStylingChanges}
|
||||
setLocalStylingChanges={setLocalStylingChanges}
|
||||
isUnsplashConfigured={isUnsplashConfigured}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeView === "settings" && (
|
||||
<SettingsView
|
||||
environment={environment}
|
||||
organizationId={organizationId}
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
segments={segments}
|
||||
responseCount={responseCount}
|
||||
membershipRole={membershipRole}
|
||||
isUserTargetingAllowed={isUserTargetingAllowed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
product={localProduct}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-200 bg-slate-100 shadow-inner md:flex md:flex-col">
|
||||
<PreviewSurvey
|
||||
survey={localSurvey}
|
||||
questionId={activeQuestionId}
|
||||
{activeView === "questions" && (
|
||||
<QuestionsView
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
product={localProduct}
|
||||
environment={environment}
|
||||
previewType={
|
||||
localSurvey.type === "app" || localSurvey.type === "website" ? "modal" : "fullwidth"
|
||||
}
|
||||
languageCode={selectedLanguageCode}
|
||||
onFileUpload={async (file) => file.name}
|
||||
invalidQuestions={invalidQuestions}
|
||||
setInvalidQuestions={setInvalidQuestions}
|
||||
selectedLanguageCode={selectedLanguageCode ? selectedLanguageCode : "default"}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isMultiLanguageAllowed={isMultiLanguageAllowed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
attributeClasses={attributeClasses}
|
||||
plan={plan}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeView === "styling" && product.styling.allowStyleOverwrite && (
|
||||
<StylingView
|
||||
colors={colors}
|
||||
environment={environment}
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
product={localProduct}
|
||||
styling={styling ?? null}
|
||||
setStyling={setStyling}
|
||||
localStylingChanges={localStylingChanges}
|
||||
setLocalStylingChanges={setLocalStylingChanges}
|
||||
isUnsplashConfigured={isUnsplashConfigured}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeView === "settings" && (
|
||||
<SettingsView
|
||||
environment={environment}
|
||||
organizationId={organizationId}
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
segments={segments}
|
||||
responseCount={responseCount}
|
||||
membershipRole={membershipRole}
|
||||
isUserTargetingAllowed={isUserTargetingAllowed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
product={localProduct}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-200 bg-slate-100 shadow-inner md:flex md:flex-col">
|
||||
<PreviewSurvey
|
||||
survey={localSurvey}
|
||||
questionId={activeQuestionId}
|
||||
product={localProduct}
|
||||
environment={environment}
|
||||
previewType={localSurvey.type === "app" || localSurvey.type === "website" ? "modal" : "fullwidth"}
|
||||
languageCode={selectedLanguageCode}
|
||||
onFileUpload={async (file) => file.name}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -178,6 +178,11 @@ export const isEndingCardValid = (
|
||||
surveyLanguages: TSurveyLanguage[]
|
||||
) => {
|
||||
if (card.type === "endScreen") {
|
||||
const parseResult = z.string().url().safeParse(card.buttonLink);
|
||||
if (card.buttonLabel !== undefined && !parseResult.success) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
isContentValid(card.headline, surveyLanguages) &&
|
||||
isContentValid(card.subheader, surveyLanguages) &&
|
||||
@@ -188,7 +193,6 @@ export const isEndingCardValid = (
|
||||
if (parseResult.success) {
|
||||
return card.label?.trim() !== "";
|
||||
} else {
|
||||
toast.error("Invalid Redirect Url in Ending card");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { UsersIcon } from "lucide-react";
|
||||
import { SegmentSettings } from "@formbricks/ee/advanced-targeting/components/segment-settings";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import { ModalWithTabs } from "@formbricks/ui/components/ModalWithTabs";
|
||||
@@ -16,7 +15,6 @@ interface EditSegmentModalProps {
|
||||
currentSegment: TSegmentWithSurveyNames;
|
||||
segments: TSegment[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
actionClasses: TActionClass[];
|
||||
isAdvancedTargetingAllowed: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
@@ -26,7 +24,6 @@ export const EditSegmentModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
currentSegment,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
segments,
|
||||
isAdvancedTargetingAllowed,
|
||||
@@ -36,7 +33,6 @@ export const EditSegmentModal = ({
|
||||
if (isAdvancedTargetingAllowed) {
|
||||
return (
|
||||
<SegmentSettings
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
environmentId={environmentId}
|
||||
initialSegment={currentSegment}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { SegmentTableDataRowContainer } from "./SegmentTableDataRowContainer";
|
||||
@@ -6,13 +5,11 @@ import { SegmentTableDataRowContainer } from "./SegmentTableDataRowContainer";
|
||||
type TSegmentTableProps = {
|
||||
segments: TSegment[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
actionClasses: TActionClass[];
|
||||
isAdvancedTargetingAllowed: boolean;
|
||||
};
|
||||
|
||||
export const SegmentTable = ({
|
||||
segments,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
isAdvancedTargetingAllowed,
|
||||
}: TSegmentTableProps) => {
|
||||
@@ -32,7 +29,6 @@ export const SegmentTable = ({
|
||||
<SegmentTableDataRowContainer
|
||||
currentSegment={segment}
|
||||
segments={segments}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
isAdvancedTargetingAllowed={isAdvancedTargetingAllowed}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { UsersIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import { EditSegmentModal } from "./EditSegmentModal";
|
||||
@@ -12,14 +11,12 @@ type TSegmentTableDataRowProps = {
|
||||
currentSegment: TSegmentWithSurveyNames;
|
||||
segments: TSegment[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
actionClasses: TActionClass[];
|
||||
isAdvancedTargetingAllowed: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
};
|
||||
|
||||
export const SegmentTableDataRow = ({
|
||||
currentSegment,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
segments,
|
||||
isAdvancedTargetingAllowed,
|
||||
@@ -65,7 +62,6 @@ export const SegmentTableDataRow = ({
|
||||
open={isEditSegmentModalOpen}
|
||||
setOpen={setIsEditSegmentModalOpen}
|
||||
currentSegment={currentSegment}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
segments={segments}
|
||||
isAdvancedTargetingAllowed={isAdvancedTargetingAllowed}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getSurveysBySegmentId } from "@formbricks/lib/survey/service";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { SegmentTableDataRow } from "./SegmentTableDataRow";
|
||||
@@ -9,14 +8,12 @@ type TSegmentTableDataRowProps = {
|
||||
currentSegment: TSegment;
|
||||
segments: TSegment[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
actionClasses: TActionClass[];
|
||||
isAdvancedTargetingAllowed: boolean;
|
||||
};
|
||||
|
||||
export const SegmentTableDataRowContainer = async ({
|
||||
currentSegment,
|
||||
segments,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
isAdvancedTargetingAllowed,
|
||||
}: TSegmentTableDataRowProps) => {
|
||||
@@ -38,7 +35,6 @@ export const SegmentTableDataRowContainer = async ({
|
||||
inactiveSurveys,
|
||||
}}
|
||||
segments={segments}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
isAdvancedTargetingAllowed={isAdvancedTargetingAllowed}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { BasicCreateSegmentModal } from "@/app/(app)/environments/[environmentId
|
||||
import { SegmentTable } from "@/app/(app)/environments/[environmentId]/(people)/segments/components/SegmentTable";
|
||||
import { CreateSegmentModal } from "@formbricks/ee/advanced-targeting/components/create-segment-modal";
|
||||
import { getAdvancedTargetingPermission } from "@formbricks/ee/lib/service";
|
||||
import { getActionClasses } from "@formbricks/lib/actionClass/service";
|
||||
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
@@ -13,11 +12,10 @@ import { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper
|
||||
import { PageHeader } from "@formbricks/ui/components/PageHeader";
|
||||
|
||||
const Page = async ({ params }) => {
|
||||
const [environment, segments, attributeClasses, actionClasses, organization] = await Promise.all([
|
||||
const [environment, segments, attributeClasses, organization] = await Promise.all([
|
||||
getEnvironment(params.environmentId),
|
||||
getSegments(params.environmentId),
|
||||
getAttributeClasses(params.environmentId),
|
||||
getActionClasses(params.environmentId),
|
||||
getOrganizationByEnvironmentId(params.environmentId),
|
||||
]);
|
||||
|
||||
@@ -41,7 +39,6 @@ const Page = async ({ params }) => {
|
||||
isAdvancedTargetingAllowed ? (
|
||||
<CreateSegmentModal
|
||||
environmentId={params.environmentId}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
segments={filteredSegments}
|
||||
/>
|
||||
@@ -60,7 +57,6 @@ const Page = async ({ params }) => {
|
||||
</PageHeader>
|
||||
<SegmentTable
|
||||
segments={filteredSegments}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
isAdvancedTargetingAllowed={isAdvancedTargetingAllowed}
|
||||
/>
|
||||
|
||||
@@ -232,7 +232,7 @@ export const AddIntegrationModal = ({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="Surveys">Questions</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{replaceHeadlineRecall(selectedSurvey, "default", attributeClasses)?.questions.map(
|
||||
(question) => (
|
||||
|
||||
@@ -30,6 +30,16 @@ const formatAddressData = (responseValue: TResponseDataValue): Record<string, st
|
||||
: {};
|
||||
};
|
||||
|
||||
const formatContactInfoData = (responseValue: TResponseDataValue): Record<string, string> => {
|
||||
const addressKeys = ["firstName", "lastName", "email", "phone", "company"];
|
||||
return Array.isArray(responseValue)
|
||||
? responseValue.reduce((acc, curr, index) => {
|
||||
acc[addressKeys[index]] = curr || ""; // Fallback to empty string if undefined
|
||||
return acc;
|
||||
}, {})
|
||||
: {};
|
||||
};
|
||||
|
||||
const extractResponseData = (response: TResponse, survey: TSurvey): Record<string, any> => {
|
||||
let responseData: Record<string, any> = {};
|
||||
|
||||
@@ -44,6 +54,9 @@ const extractResponseData = (response: TResponse, survey: TSurvey): Record<strin
|
||||
case "address":
|
||||
responseData = { ...responseData, ...formatAddressData(responseValue) };
|
||||
break;
|
||||
case "contactInfo":
|
||||
responseData = { ...responseData, ...formatContactInfoData(responseValue) };
|
||||
break;
|
||||
default:
|
||||
responseData[question.id] = responseValue;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,23 @@ const getAddressFieldLabel = (field: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getContactInfoFieldLabel = (field: string) => {
|
||||
switch (field) {
|
||||
case "firstName":
|
||||
return "First Name";
|
||||
case "lastName":
|
||||
return "Last Name";
|
||||
case "email":
|
||||
return "Email";
|
||||
case "phone":
|
||||
return "Phone";
|
||||
case "company":
|
||||
return "Company";
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const getQuestionColumnsData = (
|
||||
question: TSurveyQuestion,
|
||||
survey: TSurvey,
|
||||
@@ -88,6 +105,30 @@ const getQuestionColumnsData = (
|
||||
};
|
||||
});
|
||||
|
||||
case "contactInfo":
|
||||
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
|
||||
return contactInfoFields.map((contactInfoField) => {
|
||||
return {
|
||||
accessorKey: contactInfoField,
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">{QUESTIONS_ICON_MAP["contactInfo"]}</span>
|
||||
<span className="truncate">{getContactInfoFieldLabel(contactInfoField)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const responseValue = row.original.responseData[contactInfoField];
|
||||
if (typeof responseValue === "string") {
|
||||
return <p className="text-slate-900">{responseValue}</p>;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
default:
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getPersonIdentifier } from "@formbricks/lib/person/utils";
|
||||
import { timeSince } from "@formbricks/lib/time";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyQuestionSummaryAddress } from "@formbricks/types/surveys/types";
|
||||
import { AddressResponse } from "@formbricks/ui/components/AddressResponse";
|
||||
import { ArrayResponse } from "@formbricks/ui/components/ArrayResponse";
|
||||
import { PersonAvatar } from "@formbricks/ui/components/Avatars";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
@@ -27,7 +27,7 @@ export const AddressSummary = ({
|
||||
survey={survey}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
<div className="">
|
||||
<div>
|
||||
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
||||
<div className="pl-4 md:pl-6">User</div>
|
||||
<div className="col-span-2 pl-4 md:pl-6">Response</div>
|
||||
@@ -60,11 +60,9 @@ export const AddressSummary = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{
|
||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
|
||||
<AddressResponse value={response.value} />
|
||||
</div>
|
||||
}
|
||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
|
||||
<ArrayResponse value={response.value} />
|
||||
</div>
|
||||
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString())}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import Link from "next/link";
|
||||
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
|
||||
import { timeSince } from "@formbricks/lib/time";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyQuestionSummaryContactInfo } from "@formbricks/types/surveys/types";
|
||||
import { ArrayResponse } from "@formbricks/ui/components/ArrayResponse";
|
||||
import { PersonAvatar } from "@formbricks/ui/components/Avatars";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface ContactInfoSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryContactInfo;
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
attributeClasses: TAttributeClass[];
|
||||
}
|
||||
|
||||
export const ContactInfoSummary = ({
|
||||
questionSummary,
|
||||
environmentId,
|
||||
survey,
|
||||
attributeClasses,
|
||||
}: ContactInfoSummaryProps) => {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
<div>
|
||||
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
||||
<div className="pl-4 md:pl-6">User</div>
|
||||
<div className="col-span-2 pl-4 md:pl-6">Response</div>
|
||||
<div className="px-4 md:px-6">Time</div>
|
||||
</div>
|
||||
<div className="max-h-[62vh] w-full overflow-y-auto">
|
||||
{questionSummary.samples.map((response) => {
|
||||
return (
|
||||
<div
|
||||
key={response.id}
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.person ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/people/${response.person.id}`}>
|
||||
<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">
|
||||
{getPersonIdentifier(response.person, response.personAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
|
||||
<ArrayResponse value={response.value} />
|
||||
</div>
|
||||
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString())}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -29,6 +29,7 @@ export const SuccessMessage = ({ environment, survey }: SummaryMetadataProps) =>
|
||||
? "Almost there! Install widget to start receiving responses."
|
||||
: "Congrats! Your survey is live.",
|
||||
{
|
||||
id: "survey-publish-success-toast",
|
||||
icon: isAppSurvey && !widgetSetupCompleted ? "🤏" : "🎉",
|
||||
duration: 5000,
|
||||
position: "bottom-right",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EmptyAppSurveys } from "@/app/(app)/environments/[environmentId]/survey
|
||||
import { CTASummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary";
|
||||
import { CalSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary";
|
||||
import { ConsentSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary";
|
||||
import { ContactInfoSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ContactInfoSummary";
|
||||
import { DateQuestionSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary";
|
||||
import { FileUploadSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary";
|
||||
import { HiddenFieldsSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary";
|
||||
@@ -278,6 +279,17 @@ export const SummaryList = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.ContactInfo) {
|
||||
return (
|
||||
<ContactInfoSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environment.id}
|
||||
survey={survey}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { sendResponseFinishedEmail } from "@formbricks/email";
|
||||
@@ -115,7 +114,7 @@ export const POST = async (request: Request) => {
|
||||
},
|
||||
notificationSettings: {
|
||||
path: ["alert", surveyId],
|
||||
not: Prisma.JsonNull,
|
||||
equals: true,
|
||||
},
|
||||
},
|
||||
select: { email: true },
|
||||
|
||||
@@ -9,17 +9,13 @@ import { getDisplaysByUserId } from "@formbricks/lib/display/service";
|
||||
import { environmentCache } from "@formbricks/lib/environment/cache";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { organizationCache } from "@formbricks/lib/organization/cache";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@formbricks/lib/organization/service";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { personCache } from "@formbricks/lib/person/cache";
|
||||
import { getIsPersonMonthlyActive, getPersonByUserId } from "@formbricks/lib/person/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer";
|
||||
import { getPersonByUserId } from "@formbricks/lib/person/service";
|
||||
import { responseCache } from "@formbricks/lib/response/cache";
|
||||
import { getResponsesByUserId } from "@formbricks/lib/response/service";
|
||||
import { segmentCache } from "@formbricks/lib/segment/cache";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TJsPersonState } from "@formbricks/types/js";
|
||||
|
||||
/**
|
||||
@@ -30,7 +26,6 @@ import { TJsPersonState } from "@formbricks/types/js";
|
||||
* @returns The person state
|
||||
* @throws {ValidationError} - If the input is invalid
|
||||
* @throws {ResourceNotFoundError} - If the environment or organization is not found
|
||||
* @throws {OperationNotAllowedError} - If the MAU limit is reached and the person has not been active this month
|
||||
*/
|
||||
export const getPersonState = async ({
|
||||
environmentId,
|
||||
@@ -56,60 +51,21 @@ export const getPersonState = async ({
|
||||
throw new ResourceNotFoundError(`organization`, environmentId);
|
||||
}
|
||||
|
||||
let isMauLimitReached = false;
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
const currentMau = await getMonthlyActiveOrganizationPeopleCount(organization.id);
|
||||
const monthlyMiuLimit = organization.billing.limits.monthly.miu;
|
||||
|
||||
isMauLimitReached = monthlyMiuLimit !== null && currentMau >= monthlyMiuLimit;
|
||||
}
|
||||
|
||||
let person = await getPersonByUserId(environmentId, userId);
|
||||
|
||||
if (isMauLimitReached) {
|
||||
// MAU limit reached: check if person has been active this month; only continue if person has been active
|
||||
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
monthly: {
|
||||
miu: organization.billing.limits.monthly.miu,
|
||||
responses: organization.billing.limits.monthly.responses,
|
||||
if (!person) {
|
||||
person = await prisma.person.create({
|
||||
data: {
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Error sending plan limits reached event to Posthog: ${err}`);
|
||||
}
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
const errorMessage = `Monthly Active Users limit in the current plan is reached in ${environmentId}`;
|
||||
if (!person) {
|
||||
// if it's a new person and MAU limit is reached, throw an error
|
||||
throw new OperationNotAllowedError(errorMessage);
|
||||
}
|
||||
|
||||
// check if person has been active this month
|
||||
const isPersonMonthlyActive = await getIsPersonMonthlyActive(person.id);
|
||||
if (!isPersonMonthlyActive) {
|
||||
throw new OperationNotAllowedError(errorMessage);
|
||||
}
|
||||
} else {
|
||||
// MAU limit not reached: create person if not exists
|
||||
if (!person) {
|
||||
person = await prisma.person.create({
|
||||
data: {
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePerson = true;
|
||||
}
|
||||
revalidatePerson = true;
|
||||
}
|
||||
|
||||
const personResponses = await getResponsesByUserId(environmentId, userId);
|
||||
|
||||
@@ -32,7 +32,6 @@ export const getPersonSegmentIds = (
|
||||
const isIncluded = await evaluateSegment(
|
||||
{
|
||||
attributes,
|
||||
actionIds: [],
|
||||
deviceType,
|
||||
environmentId,
|
||||
personId: person.id,
|
||||
|
||||
@@ -7,11 +7,10 @@ import { getAttributes } from "@formbricks/lib/attribute/service";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@formbricks/lib/organization/service";
|
||||
import { createPerson, getIsPersonMonthlyActive, getPersonByUserId } from "@formbricks/lib/person/service";
|
||||
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
|
||||
import {
|
||||
capturePosthogEnvironmentEvent,
|
||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
||||
@@ -84,81 +83,31 @@ export const GET = async (
|
||||
throw new Error("Organization does not exist");
|
||||
}
|
||||
|
||||
// check if MAU limit is reached
|
||||
let isMauLimitReached = false;
|
||||
let isMonthlyResponsesLimitReached = false;
|
||||
|
||||
// check if response limit is reached
|
||||
let isAppSurveyResponseLimitReached = false;
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
const currentMau = await getMonthlyActiveOrganizationPeopleCount(organization.id);
|
||||
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
|
||||
const monthlyMiuLimit = organization.billing.limits.monthly.miu;
|
||||
|
||||
isMauLimitReached = monthlyMiuLimit !== null && currentMau >= monthlyMiuLimit;
|
||||
|
||||
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||
isMonthlyResponsesLimitReached =
|
||||
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
|
||||
|
||||
isAppSurveyResponseLimitReached =
|
||||
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
||||
|
||||
if (isAppSurveyResponseLimitReached) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: { monthly: { responses: monthlyResponseLimit, miu: null } },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error sending plan limits reached event to Posthog: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let person = await getPersonByUserId(environmentId, userId);
|
||||
|
||||
if (isMauLimitReached) {
|
||||
// MAU limit reached: check if person has been active this month; only continue if person has been active
|
||||
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
monthly: {
|
||||
miu: organization.billing.limits.monthly.miu,
|
||||
responses: organization.billing.limits.monthly.responses,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Error sending plan limits reached event to Posthog: ${err}`);
|
||||
}
|
||||
|
||||
const errorMessage = `Monthly Active Users limit in the current plan is reached in ${environmentId}`;
|
||||
if (!person) {
|
||||
// if it's a new person and MAU limit is reached, throw an error
|
||||
return responses.tooManyRequestsResponse(
|
||||
errorMessage,
|
||||
true,
|
||||
"public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600"
|
||||
);
|
||||
}
|
||||
|
||||
// check if person has been active this month
|
||||
const isPersonMonthlyActive = await getIsPersonMonthlyActive(person.id);
|
||||
if (!isPersonMonthlyActive) {
|
||||
return responses.tooManyRequestsResponse(
|
||||
errorMessage,
|
||||
true,
|
||||
"public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// MAU limit not reached: create person if not exists
|
||||
if (!person) {
|
||||
person = await createPerson(environmentId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
if (isMonthlyResponsesLimitReached) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
monthly: {
|
||||
miu: organization.billing.limits.monthly.miu,
|
||||
responses: organization.billing.limits.monthly.responses,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Error sending plan limits reached event to Posthog: ${err}`);
|
||||
}
|
||||
if (!person) {
|
||||
person = await createPerson(environmentId, userId);
|
||||
}
|
||||
|
||||
const [surveys, actionClasses] = await Promise.all([
|
||||
@@ -186,7 +135,7 @@ export const GET = async (
|
||||
|
||||
// creating state object
|
||||
let state: TJsAppStateSync = {
|
||||
surveys: !isMonthlyResponsesLimitReached
|
||||
surveys: !isAppSurveyResponseLimitReached
|
||||
? transformedSurveys.map((survey) => replaceAttributeRecall(survey, attributes))
|
||||
: [],
|
||||
actionClasses,
|
||||
|
||||
@@ -32,6 +32,7 @@ const conditionOptions = {
|
||||
consent: ["is"],
|
||||
matrix: [""],
|
||||
address: ["is"],
|
||||
contactInfo: ["is"],
|
||||
ranking: ["is"],
|
||||
};
|
||||
const filterOptions = {
|
||||
@@ -42,6 +43,7 @@ const filterOptions = {
|
||||
tags: ["Applied", "Not applied"],
|
||||
consent: ["Accepted", "Dismissed"],
|
||||
address: ["Filled out", "Skipped"],
|
||||
contactInfo: ["Filled out", "Skipped"],
|
||||
ranking: ["Filled out", "Skipped"],
|
||||
};
|
||||
|
||||
@@ -273,10 +275,11 @@ export const getFormattedFilters = (
|
||||
if (!filters.data) filters.data = {};
|
||||
switch (questionType.questionType) {
|
||||
case TSurveyQuestionTypeEnum.OpenText:
|
||||
case TSurveyQuestionTypeEnum.Address: {
|
||||
case TSurveyQuestionTypeEnum.Address:
|
||||
case TSurveyQuestionTypeEnum.ContactInfo: {
|
||||
if (filterType.filterComboBoxValue === "Filled out") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "submitted",
|
||||
op: "filledOut",
|
||||
};
|
||||
} else if (filterType.filterComboBoxValue === "Skipped") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
|
||||
@@ -16,8 +16,8 @@ export const LegalFooter = ({
|
||||
if (!IMPRINT_URL && !PRIVACY_URL && !IS_FORMBRICKS_CLOUD) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-0 h-10 w-full">
|
||||
<div className="mx-auto max-w-lg p-2 text-center text-xs text-slate-400 text-opacity-50">
|
||||
<div className="absolute bottom-0 z-[1500] h-10 w-full">
|
||||
<div className="mx-auto flex h-full max-w-lg items-center justify-center p-2 text-center text-xs text-slate-400 text-opacity-50">
|
||||
{IMPRINT_URL && (
|
||||
<Link href={IMPRINT_URL} target="_blank" className="hover:underline">
|
||||
Imprint
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.2 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 415 KiB |
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
|
||||
@@ -188,6 +188,12 @@ test.describe("Survey Create & Submit Response without logic", async () => {
|
||||
await page.getByPlaceholder(surveys.createAndSubmit.address.placeholder).fill("This is my Address");
|
||||
await page.locator("#questionCard-10").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Contact Info Question
|
||||
await expect(page.getByText(surveys.createAndSubmit.contactInfo.question)).toBeVisible();
|
||||
await expect(page.getByPlaceholder(surveys.createAndSubmit.contactInfo.placeholder)).toBeVisible();
|
||||
await page.getByPlaceholder(surveys.createAndSubmit.contactInfo.placeholder).fill("John Doe");
|
||||
await page.locator("#questionCard-11").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Ranking Question
|
||||
await expect(page.getByText(surveys.createAndSubmit.ranking.question)).toBeVisible();
|
||||
for (let i = 0; i < surveys.createAndSubmit.ranking.choices.length; i++) {
|
||||
@@ -705,6 +711,10 @@ test.describe("Testing Survey with advanced logic", async () => {
|
||||
await test.step("Verify Survey Response", async () => {
|
||||
await page.goBack();
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/);
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await page.getByRole("link").filter({ hasText: "Responses" }).click();
|
||||
await expect(page.getByRole("table")).toBeVisible();
|
||||
|
||||
@@ -286,6 +286,24 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Address" }).click();
|
||||
await page.getByLabel("Question*").fill(params.address.question);
|
||||
await page.getByRole("row", { name: "Address Line 2" }).getByRole("switch").nth(1).click();
|
||||
await page.getByRole("row", { name: "City" }).getByRole("cell").nth(2).click();
|
||||
await page.getByRole("row", { name: "State" }).getByRole("switch").nth(1).click();
|
||||
await page.getByRole("row", { name: "Zip" }).getByRole("cell").nth(2).click();
|
||||
await page.getByRole("row", { name: "Country" }).getByRole("switch").nth(1).click();
|
||||
|
||||
// Fill Contact Info Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Contact Info" }).click();
|
||||
await page.getByLabel("Question*").fill(params.contactInfo.question);
|
||||
await page.getByRole("row", { name: "Last Name" }).getByRole("switch").nth(1).click();
|
||||
await page.getByRole("row", { name: "Email" }).getByRole("switch").nth(1).click();
|
||||
await page.getByRole("row", { name: "Phone" }).getByRole("switch").nth(1).click();
|
||||
await page.getByRole("row", { name: "Company" }).getByRole("switch").nth(1).click();
|
||||
|
||||
// Fill Ranking question
|
||||
await page
|
||||
@@ -502,6 +520,11 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Address" }).click();
|
||||
await page.getByLabel("Question*").fill(params.address.question);
|
||||
await page.getByRole("row", { name: "Address Line 2" }).getByRole("switch").nth(1).click();
|
||||
await page.getByRole("row", { name: "City" }).getByRole("cell").nth(2).click();
|
||||
await page.getByRole("row", { name: "State" }).getByRole("switch").nth(1).click();
|
||||
await page.getByRole("row", { name: "Zip" }).getByRole("cell").nth(2).click();
|
||||
await page.getByRole("row", { name: "Country" }).getByRole("switch").nth(1).click();
|
||||
|
||||
// Adding logic
|
||||
// Open Text Question
|
||||
|
||||
@@ -157,6 +157,10 @@ export const surveys = {
|
||||
question: "Where do you live?",
|
||||
placeholder: "Address Line 1",
|
||||
},
|
||||
contactInfo: {
|
||||
question: "Contact Info",
|
||||
placeholder: "First Name",
|
||||
},
|
||||
ranking: {
|
||||
question: "What is most important for you in life?",
|
||||
choices: ["Work", "Money", "Travel", "Family", "Friends"],
|
||||
|
||||
@@ -16,6 +16,7 @@ Your turn 👇
|
||||
|
||||
////////////////////////////
|
||||
|
||||
» 01-October-2024 by X
|
||||
» 02-October-2024 by [@Jemeni11\_](https://x.com/Jemeni11_)
|
||||
» 03-October-2024 by [@adityadeshlahre](https://x.com/adityadeshlahre/)
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Formbricks GmbH
|
||||
Copyright (c) 2024 Formbricks GmbH
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/* eslint-disable @typescript-eslint/restrict-template-expressions -- using template strings for logging */
|
||||
|
||||
/* eslint-disable no-console -- logging is allowed in migration scripts */
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import type { TBaseFilter, TBaseFilters } from "@formbricks/types/segment";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function removeActionFilters(filters: TBaseFilters): TBaseFilters {
|
||||
const cleanedFilters = filters.reduce((acc: TBaseFilters, filter: TBaseFilter) => {
|
||||
if (Array.isArray(filter.resource)) {
|
||||
// If it's a group, recursively clean it
|
||||
const cleanedGroup = removeActionFilters(filter.resource);
|
||||
if (cleanedGroup.length > 0) {
|
||||
acc.push({
|
||||
...filter,
|
||||
resource: cleanedGroup,
|
||||
});
|
||||
}
|
||||
// @ts-expect-error -- we're checking for an older type of filter
|
||||
} else if (filter.resource.root.type !== "action") {
|
||||
// If it's not an action filter, keep it
|
||||
acc.push(filter);
|
||||
}
|
||||
// Action filters are implicitly removed by not being added to acc
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
// Ensure the first filter in the group has a null connector
|
||||
return cleanedFilters.map((filter, index) => {
|
||||
if (index === 0) {
|
||||
return { ...filter, connector: null };
|
||||
}
|
||||
return filter;
|
||||
});
|
||||
}
|
||||
|
||||
async function runMigration(): Promise<void> {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
console.log("Starting the data migration...");
|
||||
|
||||
const segmentsToUpdate = await tx.segment.findMany({});
|
||||
|
||||
console.log(`Found ${segmentsToUpdate.length} total segments`);
|
||||
|
||||
let changedFiltersCount = 0;
|
||||
|
||||
const updatePromises = segmentsToUpdate.map((segment) => {
|
||||
const updatedFilters = removeActionFilters(segment.filters);
|
||||
if (JSON.stringify(segment.filters) !== JSON.stringify(updatedFilters)) {
|
||||
changedFiltersCount++;
|
||||
}
|
||||
|
||||
return tx.segment.update({
|
||||
where: { id: segment.id },
|
||||
data: { filters: updatedFilters },
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
console.log(`Successfully updated ${changedFiltersCount} segments`);
|
||||
},
|
||||
{
|
||||
timeout: 180000, // 3 minutes
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function handleError(error: unknown): void {
|
||||
console.error("An error occurred during migration:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function handleDisconnectError(): void {
|
||||
console.error("Failed to disconnect Prisma client");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
runMigration()
|
||||
.catch(handleError)
|
||||
.finally(() => {
|
||||
prisma.$disconnect().catch(handleDisconnectError);
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,122 @@
|
||||
/* eslint-disable no-console -- logging is allowed in migration scripts */
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import {
|
||||
type TSurveyAddressQuestion,
|
||||
type TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const TRANSACTION_TIMEOUT = 30 * 60 * 1000; // 30 minutes in milliseconds
|
||||
|
||||
async function runMigration(): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
console.log("Starting data migration...");
|
||||
|
||||
await prisma.$transaction(
|
||||
async (transactionPrisma) => {
|
||||
const surveysWithAddressQuestion = await transactionPrisma.survey.findMany({
|
||||
where: {
|
||||
questions: {
|
||||
array_contains: [{ type: "address" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Found ${surveysWithAddressQuestion.length.toString()} surveys with address questions`);
|
||||
|
||||
const updationPromises = [];
|
||||
for (const survey of surveysWithAddressQuestion) {
|
||||
const updatedQuestions = survey.questions.map((question: TSurveyQuestion) => {
|
||||
if (question.type === TSurveyQuestionTypeEnum.Address) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- addressLine1 is not defined for unmigrated surveys
|
||||
if (question.addressLine1 !== undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
isAddressLine1Required,
|
||||
isAddressLine2Required,
|
||||
isCityRequired,
|
||||
isStateRequired,
|
||||
isZipRequired,
|
||||
isCountryRequired,
|
||||
...rest
|
||||
} = question as TSurveyAddressQuestion & {
|
||||
isAddressLine1Required: boolean;
|
||||
isAddressLine2Required: boolean;
|
||||
isCityRequired: boolean;
|
||||
isStateRequired: boolean;
|
||||
isZipRequired: boolean;
|
||||
isCountryRequired: boolean;
|
||||
};
|
||||
|
||||
return {
|
||||
...rest,
|
||||
addressLine1: { show: true, required: isAddressLine1Required },
|
||||
addressLine2: { show: true, required: isAddressLine2Required },
|
||||
city: { show: true, required: isCityRequired },
|
||||
state: { show: true, required: isStateRequired },
|
||||
zip: { show: true, required: isZipRequired },
|
||||
country: { show: true, required: isCountryRequired },
|
||||
};
|
||||
}
|
||||
|
||||
return question;
|
||||
});
|
||||
|
||||
const isUpdationNotRequired = updatedQuestions.some(
|
||||
(question: TSurveyQuestion | null) => question === null
|
||||
);
|
||||
|
||||
if (!isUpdationNotRequired) {
|
||||
updationPromises.push(
|
||||
transactionPrisma.survey.update({
|
||||
where: {
|
||||
id: survey.id,
|
||||
},
|
||||
data: {
|
||||
questions: updatedQuestions.filter((question: TSurveyQuestion | null) => question !== null),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (updationPromises.length === 0) {
|
||||
console.log("No surveys require migration... Exiting");
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(updationPromises);
|
||||
|
||||
console.log("Total surveys updated: ", updationPromises.length.toString());
|
||||
},
|
||||
{
|
||||
timeout: TRANSACTION_TIMEOUT,
|
||||
}
|
||||
);
|
||||
|
||||
const endTime = Date.now();
|
||||
console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toFixed(2)}s`);
|
||||
}
|
||||
|
||||
function handleError(error: unknown): void {
|
||||
console.error("An error occurred during migration:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function handleDisconnectError(): void {
|
||||
console.error("Failed to disconnect Prisma client");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
runMigration()
|
||||
.catch(handleError)
|
||||
.finally(() => {
|
||||
prisma.$disconnect().catch(handleDisconnectError);
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `Action` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Action" DROP CONSTRAINT "Action_actionClassId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Action" DROP CONSTRAINT "Action_personId_fkey";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Action";
|
||||
@@ -49,7 +49,9 @@
|
||||
"data-migration:remove-dismissed-value-inconsistency": "ts-node ./data-migrations/20240807120500_cta_consent_dismissed_inconsistency/data-migration.ts",
|
||||
"data-migration:v2.5": "pnpm data-migration:remove-dismissed-value-inconsistency",
|
||||
"data-migration:add-display-id-to-response": "ts-node ./data-migrations/20240905120500_refactor_display_response_relationship/data-migration.ts",
|
||||
"data-migration:advanced-logic": "ts-node ./data-migrations/20240828122408_advanced_logic_editor/data-migration.ts"
|
||||
"data-migration:address-question": "ts-node ./data-migrations/20240924123456_migrate_address_question/data-migration.ts",
|
||||
"data-migration:advanced-logic": "ts-node ./data-migrations/20240828122408_advanced_logic_editor/data-migration.ts",
|
||||
"data-migration:segments-actions-cleanup": "ts-node ./data-migrations/20240904091113_removed_actions_table/data-migration.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.18.0",
|
||||
|
||||
@@ -100,7 +100,6 @@ model Person {
|
||||
responses Response[]
|
||||
attributes Attribute[]
|
||||
displays Display[]
|
||||
actions Action[]
|
||||
|
||||
@@unique([environmentId, userId])
|
||||
@@index([environmentId])
|
||||
@@ -355,30 +354,12 @@ model ActionClass {
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
environmentId String
|
||||
surveyTriggers SurveyTrigger[]
|
||||
actions Action[]
|
||||
|
||||
@@unique([key, environmentId])
|
||||
@@unique([name, environmentId])
|
||||
@@index([environmentId, createdAt])
|
||||
}
|
||||
|
||||
model Action {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
actionClass ActionClass @relation(fields: [actionClassId], references: [id], onDelete: Cascade)
|
||||
actionClassId String
|
||||
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
|
||||
personId String
|
||||
/// @zod.custom(imports.ZActionProperties)
|
||||
/// @zod.custom(imports.ZActionProperties)
|
||||
/// [ActionProperties]
|
||||
properties Json @default("{}")
|
||||
|
||||
@@index([personId, actionClassId, createdAt])
|
||||
@@index([actionClassId, createdAt])
|
||||
@@index([personId, createdAt])
|
||||
}
|
||||
|
||||
enum EnvironmentType {
|
||||
production
|
||||
development
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { FingerprintIcon, MonitorSmartphoneIcon, MousePointerClick, TagIcon, Users2Icon } from "lucide-react";
|
||||
import { FingerprintIcon, MonitorSmartphoneIcon, TagIcon, Users2Icon } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import type { TActionClass } from "@formbricks/types/action-classes";
|
||||
import type { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import type {
|
||||
TBaseFilter,
|
||||
@@ -20,12 +19,11 @@ interface TAddFilterModalProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
actionClasses: TActionClass[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
segments: TSegment[];
|
||||
}
|
||||
|
||||
type TFilterType = "action" | "attribute" | "segment" | "device" | "person";
|
||||
type TFilterType = "attribute" | "segment" | "device" | "person";
|
||||
|
||||
const handleAddFilter = ({
|
||||
type,
|
||||
@@ -33,41 +31,15 @@ const handleAddFilter = ({
|
||||
setOpen,
|
||||
attributeClassName,
|
||||
deviceType,
|
||||
actionClassId,
|
||||
segmentId,
|
||||
}: {
|
||||
type: TFilterType;
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
actionClassId?: string;
|
||||
attributeClassName?: string;
|
||||
segmentId?: string;
|
||||
deviceType?: string;
|
||||
}): void => {
|
||||
if (type === "action") {
|
||||
if (!actionClassId) return;
|
||||
|
||||
const newFilter: TBaseFilter = {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
resource: {
|
||||
id: createId(),
|
||||
root: {
|
||||
type,
|
||||
actionClassId,
|
||||
},
|
||||
qualifier: {
|
||||
metric: "occuranceCount",
|
||||
operator: "greaterThan",
|
||||
},
|
||||
value: "",
|
||||
},
|
||||
};
|
||||
|
||||
onAddFilter(newFilter);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
if (type === "attribute") {
|
||||
if (!attributeClassName) return;
|
||||
|
||||
@@ -232,7 +204,6 @@ export function AddFilterModal({
|
||||
onAddFilter,
|
||||
open,
|
||||
setOpen,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
segments,
|
||||
}: TAddFilterModalProps) {
|
||||
@@ -258,14 +229,6 @@ export function AddFilterModal({
|
||||
[]
|
||||
);
|
||||
|
||||
const actionClassesFiltered = useMemo(() => {
|
||||
if (!searchValue) return actionClasses;
|
||||
|
||||
return actionClasses.filter((actionClass) =>
|
||||
actionClass.name.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
}, [actionClasses, searchValue]);
|
||||
|
||||
const attributeClassesFiltered = useMemo(() => {
|
||||
if (!attributeClasses) return [];
|
||||
|
||||
@@ -305,18 +268,11 @@ export function AddFilterModal({
|
||||
{
|
||||
attributes: attributeClassesFiltered,
|
||||
personAttributes: personAttributesFiltered,
|
||||
actions: actionClassesFiltered,
|
||||
segments: segmentsFiltered,
|
||||
devices: deviceTypesFiltered,
|
||||
},
|
||||
],
|
||||
[
|
||||
actionClassesFiltered,
|
||||
attributeClassesFiltered,
|
||||
deviceTypesFiltered,
|
||||
personAttributesFiltered,
|
||||
segmentsFiltered,
|
||||
]
|
||||
[attributeClassesFiltered, deviceTypesFiltered, personAttributesFiltered, segmentsFiltered]
|
||||
);
|
||||
|
||||
const getAllTabContent = () => {
|
||||
@@ -414,35 +370,6 @@ export function AddFilterModal({
|
||||
);
|
||||
};
|
||||
|
||||
const getActionsTabContent = () => {
|
||||
return (
|
||||
<>
|
||||
{actionClassesFiltered.length === 0 && (
|
||||
<div className="flex w-full items-center justify-center gap-4 rounded-lg px-2 py-1 text-sm">
|
||||
<p>There are no actions yet!</p>
|
||||
</div>
|
||||
)}
|
||||
{actionClassesFiltered.map((actionClass) => {
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "action",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
actionClassId: actionClass.id,
|
||||
});
|
||||
}}>
|
||||
<MousePointerClick className="h-4 w-4" />
|
||||
<p>{actionClass.name}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getAttributesTabContent = () => {
|
||||
return (
|
||||
<AttributeTabContent
|
||||
@@ -512,9 +439,6 @@ export function AddFilterModal({
|
||||
case "all": {
|
||||
return getAllTabContent();
|
||||
}
|
||||
case "actions": {
|
||||
return getActionsTabContent();
|
||||
}
|
||||
case "attributes": {
|
||||
return getAttributesTabContent();
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import React, { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import type { TActionClass } from "@formbricks/types/action-classes";
|
||||
import type { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import type {
|
||||
TBaseFilter,
|
||||
@@ -37,7 +36,6 @@ interface UserTargetingAdvancedCardProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
|
||||
environmentId: string;
|
||||
actionClasses: TActionClass[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
segments: TSegment[];
|
||||
initialSegment?: TSegment;
|
||||
@@ -47,7 +45,6 @@ export function AdvancedTargetingCard({
|
||||
localSurvey,
|
||||
setLocalSurvey,
|
||||
environmentId,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
segments,
|
||||
initialSegment,
|
||||
@@ -213,7 +210,6 @@ export function AdvancedTargetingCard({
|
||||
{Boolean(segment?.filters.length) && (
|
||||
<div className="w-full">
|
||||
<SegmentEditor
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
environmentId={environmentId}
|
||||
group={segment.filters}
|
||||
@@ -269,7 +265,6 @@ export function AdvancedTargetingCard({
|
||||
</div>
|
||||
|
||||
<AddFilterModal
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
onAddFilter={(filter) => {
|
||||
handleAddFilterInGroup(filter);
|
||||
@@ -302,7 +297,6 @@ export function AdvancedTargetingCard({
|
||||
{segmentEditorViewOnly && segment ? (
|
||||
<div className="opacity-60">
|
||||
<SegmentEditor
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
environmentId={environmentId}
|
||||
group={segment.filters}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import type { TActionClass } from "@formbricks/types/action-classes";
|
||||
import type { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import type { TBaseFilter, TSegment } from "@formbricks/types/segment";
|
||||
import { ZSegmentFilters } from "@formbricks/types/segment";
|
||||
@@ -20,15 +19,9 @@ interface TCreateSegmentModalProps {
|
||||
environmentId: string;
|
||||
segments: TSegment[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
actionClasses: TActionClass[];
|
||||
}
|
||||
|
||||
export function CreateSegmentModal({
|
||||
environmentId,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
segments,
|
||||
}: TCreateSegmentModalProps) {
|
||||
export function CreateSegmentModal({ environmentId, attributeClasses, segments }: TCreateSegmentModalProps) {
|
||||
const router = useRouter();
|
||||
const initialSegmentState = {
|
||||
title: "",
|
||||
@@ -197,7 +190,6 @@ export function CreateSegmentModal({
|
||||
)}
|
||||
|
||||
<SegmentEditor
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
environmentId={environmentId}
|
||||
group={segment.filters}
|
||||
@@ -217,7 +209,6 @@ export function CreateSegmentModal({
|
||||
</Button>
|
||||
|
||||
<AddFilterModal
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
onAddFilter={(filter) => {
|
||||
handleAddFilterInGroup(filter);
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
moveResource,
|
||||
toggleGroupConnector,
|
||||
} from "@formbricks/lib/segment/utils";
|
||||
import type { TActionClass } from "@formbricks/types/action-classes";
|
||||
import type { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import type { TBaseFilter, TBaseFilters, TSegment, TSegmentConnector } from "@formbricks/types/segment";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
@@ -29,7 +28,6 @@ interface TSegmentEditorProps {
|
||||
environmentId: string;
|
||||
segment: TSegment;
|
||||
segments: TSegment[];
|
||||
actionClasses: TActionClass[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
setSegment: React.Dispatch<React.SetStateAction<TSegment | null>>;
|
||||
viewOnly?: boolean;
|
||||
@@ -40,7 +38,6 @@ export function SegmentEditor({
|
||||
environmentId,
|
||||
setSegment,
|
||||
segment,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
segments,
|
||||
viewOnly = false,
|
||||
@@ -122,7 +119,6 @@ export function SegmentEditor({
|
||||
if (isResourceFilter(resource)) {
|
||||
return (
|
||||
<SegmentFilter
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
connector={connector}
|
||||
environmentId={environmentId}
|
||||
@@ -165,7 +161,6 @@ export function SegmentEditor({
|
||||
|
||||
<div className="rounded-lg border-2 border-slate-300 bg-white p-4">
|
||||
<SegmentEditor
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
environmentId={environmentId}
|
||||
group={resource}
|
||||
@@ -189,7 +184,6 @@ export function SegmentEditor({
|
||||
</div>
|
||||
|
||||
<AddFilterModal
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
onAddFilter={(filter) => {
|
||||
if (addFilterModalOpenedFromBelow) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
FingerprintIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
MoreVertical,
|
||||
MousePointerClick,
|
||||
TagIcon,
|
||||
Trash2,
|
||||
Users2Icon,
|
||||
@@ -12,31 +11,24 @@ import { z } from "zod";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import {
|
||||
convertMetricToText,
|
||||
convertOperatorToText,
|
||||
convertOperatorToTitle,
|
||||
toggleFilterConnector,
|
||||
updateActionClassIdInFilter,
|
||||
updateAttributeClassNameInFilter,
|
||||
updateDeviceTypeInFilter,
|
||||
updateFilterValue,
|
||||
updateMetricInFilter,
|
||||
updateOperatorInFilter,
|
||||
updatePersonIdentifierInFilter,
|
||||
updateSegmentIdInFilter,
|
||||
} from "@formbricks/lib/segment/utils";
|
||||
import { isCapitalized } from "@formbricks/lib/utils/strings";
|
||||
import type { TActionClass } from "@formbricks/types/action-classes";
|
||||
import type { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import type {
|
||||
TActionMetric,
|
||||
TArithmeticOperator,
|
||||
TAttributeOperator,
|
||||
TBaseFilter,
|
||||
TBaseOperator,
|
||||
TDeviceOperator,
|
||||
TSegment,
|
||||
TSegmentActionFilter,
|
||||
TSegmentAttributeFilter,
|
||||
TSegmentConnector,
|
||||
TSegmentDeviceFilter,
|
||||
@@ -47,10 +39,8 @@ import type {
|
||||
TSegmentSegmentFilter,
|
||||
} from "@formbricks/types/segment";
|
||||
import {
|
||||
ACTION_METRICS,
|
||||
ARITHMETIC_OPERATORS,
|
||||
ATTRIBUTE_OPERATORS,
|
||||
BASE_OPERATORS,
|
||||
DEVICE_OPERATORS,
|
||||
PERSON_OPERATORS,
|
||||
} from "@formbricks/types/segment";
|
||||
@@ -77,7 +67,6 @@ interface TSegmentFilterProps {
|
||||
environmentId: string;
|
||||
segment: TSegment;
|
||||
segments: TSegment[];
|
||||
actionClasses: TActionClass[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
setSegment: (segment: TSegment) => void;
|
||||
handleAddFilterBelow: (resourceId: string, filter: TBaseFilter) => void;
|
||||
@@ -566,188 +555,6 @@ function PersonSegmentFilter({
|
||||
);
|
||||
}
|
||||
|
||||
type TActionSegmentFilterProps = TSegmentFilterProps & {
|
||||
onAddFilterBelow: () => void;
|
||||
resource: TSegmentActionFilter;
|
||||
updateValueInLocalSurvey: (filterId: string, newValue: TSegmentFilterValue) => void;
|
||||
};
|
||||
function ActionSegmentFilter({
|
||||
connector,
|
||||
resource,
|
||||
segment,
|
||||
setSegment,
|
||||
onAddFilterBelow,
|
||||
onCreateGroup,
|
||||
onDeleteFilter,
|
||||
onMoveFilter,
|
||||
updateValueInLocalSurvey,
|
||||
actionClasses,
|
||||
viewOnly,
|
||||
}: TActionSegmentFilterProps) {
|
||||
const { actionClassId } = resource.root;
|
||||
const operatorText = convertOperatorToText(resource.qualifier.operator);
|
||||
const qualifierMetric = resource.qualifier.metric;
|
||||
|
||||
const [valueError, setValueError] = useState("");
|
||||
|
||||
const operatorArr = BASE_OPERATORS.map((operator) => ({
|
||||
id: operator,
|
||||
name: convertOperatorToText(operator),
|
||||
}));
|
||||
|
||||
const actionMetrics = ACTION_METRICS.map((metric) => ({
|
||||
id: metric,
|
||||
name: convertMetricToText(metric),
|
||||
}));
|
||||
|
||||
const actionClass = actionClasses.find((actionClass) => actionClass.id === actionClassId)?.name;
|
||||
|
||||
const updateOperatorInSegment = (filterId: string, newOperator: TBaseOperator) => {
|
||||
const updatedSegment = structuredClone(segment);
|
||||
if (updatedSegment.filters) {
|
||||
updateOperatorInFilter(updatedSegment.filters, filterId, newOperator);
|
||||
}
|
||||
|
||||
setSegment(updatedSegment);
|
||||
};
|
||||
|
||||
const updateActionClassIdInSegment = (filterId: string, actionClassId: string) => {
|
||||
const updatedSegment = structuredClone(segment);
|
||||
if (updatedSegment.filters) {
|
||||
updateActionClassIdInFilter(updatedSegment.filters, filterId, actionClassId);
|
||||
}
|
||||
|
||||
setSegment(updatedSegment);
|
||||
};
|
||||
|
||||
const updateActionMetricInLocalSurvey = (filterId: string, newMetric: TActionMetric) => {
|
||||
const updatedSegment = structuredClone(segment);
|
||||
if (updatedSegment.filters) {
|
||||
updateMetricInFilter(updatedSegment.filters, filterId, newMetric);
|
||||
}
|
||||
|
||||
setSegment(updatedSegment);
|
||||
};
|
||||
|
||||
const checkValueAndUpdate = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = e.target;
|
||||
updateValueInLocalSurvey(resource.id, value);
|
||||
|
||||
if (!value) {
|
||||
setValueError("Value cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
const isNumber = z.coerce.number().safeParse(value);
|
||||
|
||||
if (isNumber.success) {
|
||||
setValueError("");
|
||||
updateValueInLocalSurvey(resource.id, parseInt(value, 10));
|
||||
} else {
|
||||
setValueError("Value must be a number");
|
||||
updateValueInLocalSurvey(resource.id, value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<SegmentFilterItemConnector
|
||||
connector={connector}
|
||||
filterId={resource.id}
|
||||
key={connector}
|
||||
segment={segment}
|
||||
setSegment={setSegment}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
<Select
|
||||
disabled={viewOnly}
|
||||
onValueChange={(value) => {
|
||||
updateActionClassIdInSegment(resource.id, value);
|
||||
}}
|
||||
value={actionClass}>
|
||||
<SelectTrigger
|
||||
className="w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
|
||||
hideArrow>
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-1">
|
||||
<MousePointerClick className="h-4 w-4 text-sm" />
|
||||
<p>{actionClass}</p>
|
||||
</div>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bottom-0">
|
||||
{actionClasses.map((actionClass) => (
|
||||
<SelectItem value={actionClass.id}>{actionClass.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
disabled={viewOnly}
|
||||
onValueChange={(value: TActionMetric) => {
|
||||
updateActionMetricInLocalSurvey(resource.id, value);
|
||||
}}
|
||||
value={qualifierMetric}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
|
||||
hideArrow>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{actionMetrics.map((metric) => (
|
||||
<SelectItem value={metric.id}>{metric.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
disabled={viewOnly}
|
||||
onValueChange={(operator: TBaseOperator) => {
|
||||
updateOperatorInSegment(resource.id, operator);
|
||||
}}
|
||||
value={operatorText}>
|
||||
<SelectTrigger
|
||||
className="flex w-full max-w-[40px] items-center justify-center bg-white text-center"
|
||||
hideArrow>
|
||||
<SelectValue>
|
||||
<p>{operatorText}</p>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{operatorArr.map((operator) => (
|
||||
<SelectItem title={convertOperatorToTitle(operator.id)} value={operator.id}>
|
||||
{operator.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative flex flex-col gap-1">
|
||||
<Input
|
||||
className={cn("w-auto bg-white", valueError && "border border-red-500 focus:border-red-500")}
|
||||
disabled={viewOnly}
|
||||
onChange={(e) => {
|
||||
if (viewOnly) return;
|
||||
checkValueAndUpdate(e);
|
||||
}}
|
||||
value={resource.value}
|
||||
/>
|
||||
|
||||
{valueError ? (
|
||||
<p className="absolute right-2 -mt-1 rounded-md bg-white px-2 text-xs text-red-500">{valueError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<SegmentFilterItemContextMenu
|
||||
filterId={resource.id}
|
||||
onAddFilterBelow={onAddFilterBelow}
|
||||
onCreateGroup={onCreateGroup}
|
||||
onDeleteFilter={onDeleteFilter}
|
||||
onMoveFilter={onMoveFilter}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TSegmentSegmentFilterProps = TSegmentFilterProps & {
|
||||
onAddFilterBelow: () => void;
|
||||
resource: TSegmentSegmentFilter;
|
||||
@@ -972,7 +779,6 @@ export function SegmentFilter({
|
||||
environmentId,
|
||||
segment,
|
||||
segments,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
setSegment,
|
||||
handleAddFilterBelow,
|
||||
@@ -998,7 +804,6 @@ export function SegmentFilter({
|
||||
function RenderFilterModal() {
|
||||
return (
|
||||
<AddFilterModal
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
onAddFilter={(filter) => {
|
||||
handleAddFilterBelow(resource.id, filter);
|
||||
@@ -1011,36 +816,10 @@ export function SegmentFilter({
|
||||
}
|
||||
|
||||
switch (resource.root.type) {
|
||||
case "action":
|
||||
return (
|
||||
<>
|
||||
<ActionSegmentFilter
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
connector={connector}
|
||||
environmentId={environmentId}
|
||||
handleAddFilterBelow={handleAddFilterBelow}
|
||||
onAddFilterBelow={onAddFilterBelow}
|
||||
onCreateGroup={onCreateGroup}
|
||||
onDeleteFilter={onDeleteFilter}
|
||||
onMoveFilter={onMoveFilter}
|
||||
resource={resource as TSegmentActionFilter}
|
||||
segment={segment}
|
||||
segments={segments}
|
||||
setSegment={setSegment}
|
||||
updateValueInLocalSurvey={updateFilterValueInSegment}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
|
||||
<RenderFilterModal />
|
||||
</>
|
||||
);
|
||||
|
||||
case "attribute":
|
||||
return (
|
||||
<>
|
||||
<AttributeSegmentFilter
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
connector={connector}
|
||||
environmentId={environmentId}
|
||||
@@ -1065,7 +844,6 @@ export function SegmentFilter({
|
||||
return (
|
||||
<>
|
||||
<PersonSegmentFilter
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
connector={connector}
|
||||
environmentId={environmentId}
|
||||
@@ -1090,7 +868,6 @@ export function SegmentFilter({
|
||||
return (
|
||||
<>
|
||||
<SegmentSegmentFilter
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
connector={connector}
|
||||
environmentId={environmentId}
|
||||
@@ -1114,7 +891,6 @@ export function SegmentFilter({
|
||||
return (
|
||||
<>
|
||||
<DeviceFilter
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
connector={connector}
|
||||
environmentId={environmentId}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import type { TActionClass } from "@formbricks/types/action-classes";
|
||||
import type { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import type { TBaseFilter, TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import { ZSegmentFilters } from "@formbricks/types/segment";
|
||||
@@ -23,14 +22,12 @@ interface TSegmentSettingsTabProps {
|
||||
initialSegment: TSegmentWithSurveyNames;
|
||||
segments: TSegment[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
actionClasses: TActionClass[];
|
||||
}
|
||||
|
||||
export function SegmentSettings({
|
||||
environmentId,
|
||||
initialSegment,
|
||||
setOpen,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
segments,
|
||||
}: TSegmentSettingsTabProps) {
|
||||
@@ -182,7 +179,6 @@ export function SegmentSettings({
|
||||
)}
|
||||
|
||||
<SegmentEditor
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
environmentId={environmentId}
|
||||
group={segment.filters}
|
||||
@@ -203,7 +199,6 @@ export function SegmentSettings({
|
||||
</div>
|
||||
|
||||
<AddFilterModal
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
onAddFilter={(filter) => {
|
||||
handleAddFilterInGroup(filter);
|
||||
|
||||
@@ -436,6 +436,7 @@ export function PreviewEmailTemplate({
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.Address:
|
||||
case TSurveyQuestionTypeEnum.ContactInfo:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Text className="text-question-color m-0 mr-8 block p-0 text-base font-semibold leading-6">
|
||||
@@ -444,14 +445,7 @@ export function PreviewEmailTemplate({
|
||||
<Text className="text-question-color m-0 block p-0 text-sm font-normal leading-6">
|
||||
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
|
||||
</Text>
|
||||
{[
|
||||
"Address Line 1",
|
||||
"Address Line 2",
|
||||
"City / Town",
|
||||
"State / Region",
|
||||
"ZIP / Post Code",
|
||||
"Country",
|
||||
].map((label) => (
|
||||
{["First Name", "Last Name", "Email", "Phone", "Company"].map((label) => (
|
||||
<Section
|
||||
className="border-input-border-color bg-input-color rounded-custom mt-4 block h-10 w-full border border-solid py-2 pl-2 text-slate-400"
|
||||
key={label}>
|
||||
@@ -461,6 +455,7 @@ export function PreviewEmailTemplate({
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
|
||||
case TSurveyQuestionTypeEnum.FileUpload:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Formbricks GmbH
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Formbricks GmbH
|
||||
Copyright (c) 2024 Formbricks GmbH
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
e.parentNode.insertBefore(t, e),
|
||||
setTimeout(function () {
|
||||
formbricks.init({
|
||||
environmentId: "cm14wcs5m0005b3aezc4a6ejf",
|
||||
environmentId: "cm1qbbvo8000c5ij3dt7qmyn6",
|
||||
userId: "RANDOM_USER_ID",
|
||||
apiHost: "http://localhost:3000",
|
||||
});
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
interface RevalidateProps {
|
||||
environmentId?: string;
|
||||
personId?: string;
|
||||
}
|
||||
|
||||
export const actionCache = {
|
||||
tag: {
|
||||
byEnvironmentId(environmentId: string): string {
|
||||
return `environments-${environmentId}-actions`;
|
||||
},
|
||||
byPersonId(personId: string): string {
|
||||
return `environments-${personId}-actions`;
|
||||
},
|
||||
},
|
||||
revalidate({ environmentId, personId }: RevalidateProps): void {
|
||||
if (environmentId) {
|
||||
revalidateTag(this.tag.byEnvironmentId(environmentId));
|
||||
}
|
||||
if (personId) {
|
||||
revalidateTag(this.tag.byPersonId(personId));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,442 +0,0 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { differenceInDays } from "date-fns";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TAction, TActionInput, ZActionInput } from "@formbricks/types/actions";
|
||||
import { ZOptionalNumber } from "@formbricks/types/common";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { actionClassCache } from "../actionClass/cache";
|
||||
import { getActionClassByEnvironmentIdAndName } from "../actionClass/service";
|
||||
import { cache } from "../cache";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { activePersonCache } from "../person/cache";
|
||||
import { getIsPersonMonthlyActive } from "../person/service";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { actionCache } from "./cache";
|
||||
import { getStartDateOfLastMonth, getStartDateOfLastQuarter, getStartDateOfLastWeek } from "./utils";
|
||||
|
||||
export const getActionsByPersonId = reactCache(
|
||||
async (personId: string, page?: number): Promise<TAction[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([personId, ZId], [page, ZOptionalNumber]);
|
||||
|
||||
try {
|
||||
const actionsPrisma = await prisma.action.findMany({
|
||||
where: {
|
||||
person: {
|
||||
id: personId,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: page ? ITEMS_PER_PAGE : undefined,
|
||||
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
||||
include: {
|
||||
actionClass: true,
|
||||
},
|
||||
});
|
||||
|
||||
return actionsPrisma.map((action) => ({
|
||||
id: action.id,
|
||||
createdAt: action.createdAt,
|
||||
personId: action.personId,
|
||||
properties: action.properties,
|
||||
actionClass: action.actionClass,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getActionsByPersonId-${personId}-${page}`],
|
||||
{
|
||||
tags: [actionCache.tag.byPersonId(personId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getActionsByEnvironmentId = reactCache(
|
||||
async (environmentId: string, page?: number): Promise<TAction[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
|
||||
|
||||
try {
|
||||
const actionsPrisma = await prisma.action.findMany({
|
||||
where: {
|
||||
actionClass: {
|
||||
environmentId: environmentId,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: page ? ITEMS_PER_PAGE : undefined,
|
||||
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
||||
include: {
|
||||
actionClass: true,
|
||||
},
|
||||
});
|
||||
const actions: TAction[] = [];
|
||||
// transforming response to type TAction[]
|
||||
actionsPrisma.forEach((action) => {
|
||||
actions.push({
|
||||
id: action.id,
|
||||
createdAt: action.createdAt,
|
||||
// sessionId: action.sessionId,
|
||||
personId: action.personId,
|
||||
properties: action.properties,
|
||||
actionClass: action.actionClass,
|
||||
});
|
||||
});
|
||||
return actions;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getActionsByEnvironmentId-${environmentId}-${page}`],
|
||||
{
|
||||
tags: [actionCache.tag.byEnvironmentId(environmentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const createAction = async (data: TActionInput): Promise<TAction> => {
|
||||
validateInputs([data, ZActionInput]);
|
||||
|
||||
try {
|
||||
const { environmentId, name, userId } = data;
|
||||
|
||||
let actionClass = await getActionClassByEnvironmentIdAndName(environmentId, name);
|
||||
|
||||
if (!actionClass) {
|
||||
throw new OperationNotAllowedError(
|
||||
`${name} action unknown. Please add this action in Formbricks first in order to use it in your code.`
|
||||
);
|
||||
}
|
||||
|
||||
const action = await prisma.action.create({
|
||||
data: {
|
||||
person: {
|
||||
connect: {
|
||||
environmentId_userId: {
|
||||
environmentId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
actionClass: {
|
||||
connect: {
|
||||
id: actionClass.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const isPersonMonthlyActive = await getIsPersonMonthlyActive(action.personId);
|
||||
if (!isPersonMonthlyActive) {
|
||||
activePersonCache.revalidate({ id: action.personId });
|
||||
}
|
||||
|
||||
actionCache.revalidate({
|
||||
environmentId,
|
||||
personId: action.personId,
|
||||
});
|
||||
|
||||
return {
|
||||
id: action.id,
|
||||
createdAt: action.createdAt,
|
||||
personId: action.personId,
|
||||
properties: action.properties,
|
||||
actionClass,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getActionCountInLastHour = reactCache(
|
||||
async (actionClassId: string): Promise<number> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([actionClassId, ZId]);
|
||||
|
||||
try {
|
||||
const numEventsLastHour = await prisma.action.count({
|
||||
where: {
|
||||
actionClassId: actionClassId,
|
||||
createdAt: {
|
||||
gte: new Date(Date.now() - 60 * 60 * 1000),
|
||||
},
|
||||
},
|
||||
});
|
||||
return numEventsLastHour;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getActionCountInLastHour-${actionClassId}`],
|
||||
{
|
||||
tags: [actionClassCache.tag.byId(actionClassId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getActionCountInLast24Hours = reactCache(
|
||||
async (actionClassId: string): Promise<number> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([actionClassId, ZId]);
|
||||
|
||||
try {
|
||||
const numEventsLast24Hours = await prisma.action.count({
|
||||
where: {
|
||||
actionClassId: actionClassId,
|
||||
createdAt: {
|
||||
gte: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||
},
|
||||
},
|
||||
});
|
||||
return numEventsLast24Hours;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getActionCountInLast24Hours-${actionClassId}`],
|
||||
{
|
||||
tags: [actionClassCache.tag.byId(actionClassId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getActionCountInLast7Days = reactCache(
|
||||
async (actionClassId: string): Promise<number> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([actionClassId, ZId]);
|
||||
|
||||
try {
|
||||
const numEventsLast7Days = await prisma.action.count({
|
||||
where: {
|
||||
actionClassId: actionClassId,
|
||||
createdAt: {
|
||||
gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
},
|
||||
});
|
||||
return numEventsLast7Days;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getActionCountInLast7Days-${actionClassId}`],
|
||||
{
|
||||
tags: [actionClassCache.tag.byId(actionClassId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getActionCountInLastQuarter = reactCache(
|
||||
async (actionClassId: string, personId: string): Promise<number> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([actionClassId, ZId], [personId, ZId]);
|
||||
|
||||
try {
|
||||
const numEventsLastQuarter = await prisma.action.count({
|
||||
where: {
|
||||
personId,
|
||||
actionClass: {
|
||||
id: actionClassId,
|
||||
},
|
||||
createdAt: {
|
||||
gte: getStartDateOfLastQuarter(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return numEventsLastQuarter;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getActionCountInLastQuarter-${actionClassId}-${personId}`],
|
||||
{
|
||||
tags: [actionClassCache.tag.byId(actionClassId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getActionCountInLastMonth = reactCache(
|
||||
async (actionClassId: string, personId: string): Promise<number> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([actionClassId, ZId], [personId, ZId]);
|
||||
|
||||
try {
|
||||
const numEventsLastMonth = await prisma.action.count({
|
||||
where: {
|
||||
personId,
|
||||
actionClass: {
|
||||
id: actionClassId,
|
||||
},
|
||||
createdAt: {
|
||||
gte: getStartDateOfLastMonth(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return numEventsLastMonth;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getActionCountInLastMonth-${actionClassId}-${personId}`],
|
||||
{
|
||||
tags: [actionClassCache.tag.byId(actionClassId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getActionCountInLastWeek = reactCache(
|
||||
async (actionClassId: string, personId: string): Promise<number> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([actionClassId, ZId], [personId, ZId]);
|
||||
|
||||
try {
|
||||
const numEventsLastWeek = await prisma.action.count({
|
||||
where: {
|
||||
personId,
|
||||
actionClass: {
|
||||
id: actionClassId,
|
||||
},
|
||||
createdAt: {
|
||||
gte: getStartDateOfLastWeek(),
|
||||
},
|
||||
},
|
||||
});
|
||||
return numEventsLastWeek;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getActionCountInLastWeek-${actionClassId}-${personId}`],
|
||||
{
|
||||
tags: [actionClassCache.tag.byId(actionClassId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getTotalOccurrencesForAction = reactCache(
|
||||
async (actionClassId: string, personId: string): Promise<number> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([actionClassId, ZId], [personId, ZId]);
|
||||
|
||||
try {
|
||||
const count = await prisma.action.count({
|
||||
where: {
|
||||
personId,
|
||||
actionClass: {
|
||||
id: actionClassId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return count;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getTotalOccurrencesForAction-${actionClassId}-${personId}`],
|
||||
{
|
||||
tags: [actionClassCache.tag.byId(actionClassId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getLastOccurrenceDaysAgo = reactCache(
|
||||
async (actionClassId: string, personId: string): Promise<number | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([actionClassId, ZId], [personId, ZId]);
|
||||
|
||||
try {
|
||||
const lastEvent = await prisma.action.findFirst({
|
||||
where: {
|
||||
personId,
|
||||
actionClass: {
|
||||
id: actionClassId,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
select: {
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!lastEvent) return null;
|
||||
return differenceInDays(new Date(), lastEvent.createdAt);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getLastOccurrenceDaysAgo-${actionClassId}-${personId}`],
|
||||
{
|
||||
tags: [actionClassCache.tag.byId(actionClassId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getFirstOccurrenceDaysAgo = reactCache(
|
||||
async (actionClassId: string, personId: string): Promise<number | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([actionClassId, ZId], [personId, ZId]);
|
||||
|
||||
try {
|
||||
const firstEvent = await prisma.action.findFirst({
|
||||
where: {
|
||||
personId,
|
||||
actionClass: {
|
||||
id: actionClassId,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
select: {
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!firstEvent) return null;
|
||||
return differenceInDays(new Date(), firstEvent.createdAt);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getFirstOccurrenceDaysAgo-${actionClassId}-${personId}`],
|
||||
{
|
||||
tags: [actionClassCache.tag.byId(actionClassId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -1,13 +0,0 @@
|
||||
import { startOfMonth, startOfQuarter, startOfWeek, subMonths, subQuarters, subWeeks } from "date-fns";
|
||||
|
||||
export const getStartDateOfLastQuarter = () => {
|
||||
return startOfQuarter(subQuarters(new Date(), 1));
|
||||
};
|
||||
|
||||
export const getStartDateOfLastMonth = () => {
|
||||
return startOfMonth(subMonths(new Date(), 1));
|
||||
};
|
||||
|
||||
export const getStartDateOfLastWeek = () => {
|
||||
return startOfWeek(subWeeks(new Date(), 1));
|
||||
};
|
||||
@@ -11,6 +11,19 @@ import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { integrationCache } from "./cache";
|
||||
|
||||
const transformIntegration = (integration: TIntegration): TIntegration => {
|
||||
return {
|
||||
...integration,
|
||||
config: {
|
||||
...integration.config,
|
||||
data: integration.config.data.map((data) => ({
|
||||
...data,
|
||||
createdAt: new Date(data.createdAt),
|
||||
})),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const createOrUpdateIntegration = async (
|
||||
environmentId: string,
|
||||
integrationData: TIntegrationInput
|
||||
@@ -75,7 +88,9 @@ export const getIntegrations = reactCache(
|
||||
{
|
||||
tags: [integrationCache.tag.byEnvironmentId(environmentId)],
|
||||
}
|
||||
)()
|
||||
)().then((cachedIntegration) => {
|
||||
return cachedIntegration.map((integration) => transformIntegration(integration));
|
||||
})
|
||||
);
|
||||
|
||||
export const getIntegration = reactCache(
|
||||
@@ -130,7 +145,11 @@ export const getIntegrationByType = reactCache(
|
||||
{
|
||||
tags: [integrationCache.tag.byEnvironmentIdAndType(environmentId, type)],
|
||||
}
|
||||
)()
|
||||
)().then((cachedIntegration) => {
|
||||
if (cachedIntegration) {
|
||||
return transformIntegration(cachedIntegration);
|
||||
} else return null;
|
||||
})
|
||||
);
|
||||
|
||||
export const deleteIntegration = async (integrationId: string): Promise<TIntegration> => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { TPerson, TPersonWithAttributes } from "@formbricks/types/people";
|
||||
import { cache } from "../cache";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { activePersonCache, personCache } from "./cache";
|
||||
import { personCache } from "./cache";
|
||||
|
||||
export const selectPerson = {
|
||||
id: true,
|
||||
@@ -284,39 +284,3 @@ export const getPersonByUserId = reactCache(
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getIsPersonMonthlyActive = reactCache(
|
||||
(personId: string): Promise<boolean> =>
|
||||
cache(
|
||||
async () => {
|
||||
try {
|
||||
const latestAction = await prisma.action.findFirst({
|
||||
where: {
|
||||
personId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
select: {
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
if (!latestAction || new Date(latestAction.createdAt).getMonth() !== new Date().getMonth()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getIsPersonMonthlyActive-${personId}`],
|
||||
{
|
||||
tags: [activePersonCache.tag.byId(personId)],
|
||||
revalidate: 60 * 60 * 24, // 24 hours
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyContactInfoQuestion,
|
||||
TSurveyLanguage,
|
||||
TSurveyMultipleChoiceQuestion,
|
||||
TSurveyQuestion,
|
||||
@@ -212,6 +213,14 @@ export const buildWhereClause = (survey: TSurvey, filterCriteria?: TResponseFilt
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "filledOut":
|
||||
data.push({
|
||||
data: {
|
||||
path: [key],
|
||||
not: [],
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "skipped":
|
||||
data.push({
|
||||
OR: [
|
||||
@@ -385,7 +394,6 @@ export const buildWhereClause = (survey: TSurvey, filterCriteria?: TResponseFilt
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case "uploaded":
|
||||
data.push({
|
||||
data: {
|
||||
@@ -1271,7 +1279,8 @@ export const getQuestionWiseSummary = (
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Address: {
|
||||
case TSurveyQuestionTypeEnum.Address:
|
||||
case TSurveyQuestionTypeEnum.ContactInfo: {
|
||||
let values: TSurveyQuestionSummaryAddress["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
@@ -1287,8 +1296,8 @@ export const getQuestionWiseSummary = (
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
type: question.type as TSurveyQuestionTypeEnum.ContactInfo,
|
||||
question: question as TSurveyContactInfoQuestion,
|
||||
responseCount: values.length,
|
||||
samples: values.slice(0, VALUES_LIMIT),
|
||||
});
|
||||
|
||||
@@ -10,13 +10,11 @@ import {
|
||||
ValidationError,
|
||||
} from "@formbricks/types/errors";
|
||||
import {
|
||||
TActionMetric,
|
||||
TAllOperators,
|
||||
TBaseFilters,
|
||||
TEvaluateSegmentUserAttributeData,
|
||||
TEvaluateSegmentUserData,
|
||||
TSegment,
|
||||
TSegmentActionFilter,
|
||||
TSegmentAttributeFilter,
|
||||
TSegmentConnector,
|
||||
TSegmentCreateInput,
|
||||
@@ -28,14 +26,6 @@ import {
|
||||
ZSegmentFilters,
|
||||
ZSegmentUpdateInput,
|
||||
} from "@formbricks/types/segment";
|
||||
import {
|
||||
getActionCountInLastMonth,
|
||||
getActionCountInLastQuarter,
|
||||
getActionCountInLastWeek,
|
||||
getFirstOccurrenceDaysAgo,
|
||||
getLastOccurrenceDaysAgo,
|
||||
getTotalOccurrencesForAction,
|
||||
} from "../action/service";
|
||||
import { cache } from "../cache";
|
||||
import { structuredClone } from "../pollyfills/structuredClone";
|
||||
import { surveyCache } from "../survey/cache";
|
||||
@@ -466,68 +456,6 @@ const evaluatePersonFilter = (userId: string, filter: TSegmentPersonFilter): boo
|
||||
return false;
|
||||
};
|
||||
|
||||
const getResolvedActionValue = async (actionClassId: string, personId: string, metric: TActionMetric) => {
|
||||
if (metric === "lastQuarterCount") {
|
||||
const lastQuarterCount = await getActionCountInLastQuarter(actionClassId, personId);
|
||||
return lastQuarterCount;
|
||||
}
|
||||
|
||||
if (metric === "lastMonthCount") {
|
||||
const lastMonthCount = await getActionCountInLastMonth(actionClassId, personId);
|
||||
return lastMonthCount;
|
||||
}
|
||||
|
||||
if (metric === "lastWeekCount") {
|
||||
const lastWeekCount = await getActionCountInLastWeek(actionClassId, personId);
|
||||
return lastWeekCount;
|
||||
}
|
||||
|
||||
if (metric === "lastOccurranceDaysAgo") {
|
||||
const lastOccurranceDaysAgo = await getLastOccurrenceDaysAgo(actionClassId, personId);
|
||||
return lastOccurranceDaysAgo;
|
||||
}
|
||||
|
||||
if (metric === "firstOccurranceDaysAgo") {
|
||||
const firstOccurranceDaysAgo = await getFirstOccurrenceDaysAgo(actionClassId, personId);
|
||||
return firstOccurranceDaysAgo;
|
||||
}
|
||||
|
||||
if (metric === "occuranceCount") {
|
||||
const occuranceCount = await getTotalOccurrencesForAction(actionClassId, personId);
|
||||
return occuranceCount;
|
||||
}
|
||||
};
|
||||
|
||||
const evaluateActionFilter = async (
|
||||
actionClassIds: string[],
|
||||
filter: TSegmentActionFilter,
|
||||
personId: string
|
||||
): Promise<boolean> => {
|
||||
const { value, qualifier, root } = filter;
|
||||
const { actionClassId } = root;
|
||||
const { metric } = qualifier;
|
||||
|
||||
// there could be a case when the actionIds do not have the actionClassId
|
||||
// in such a case, we return false
|
||||
|
||||
const actionClassIdIndex = actionClassIds.findIndex((actionId) => actionId === actionClassId);
|
||||
if (actionClassIdIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// we have the action metric and we'll need to find out the values for those metrics from the db
|
||||
const actionValue = await getResolvedActionValue(actionClassId, personId, metric);
|
||||
|
||||
const actionResult =
|
||||
actionValue !== undefined && compareValues(actionValue ?? 0, value, qualifier.operator);
|
||||
|
||||
return actionResult;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const evaluateSegmentFilter = async (
|
||||
userData: TEvaluateSegmentUserData,
|
||||
filter: TSegmentSegmentFilter
|
||||
@@ -642,19 +570,6 @@ export const evaluateSegment = async (
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "action") {
|
||||
result = await evaluateActionFilter(
|
||||
userData.actionIds,
|
||||
resource as TSegmentActionFilter,
|
||||
userData.personId
|
||||
);
|
||||
|
||||
resultPairs.push({
|
||||
result,
|
||||
connector: filterItem.connector,
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "segment") {
|
||||
result = await evaluateSegmentFilter(userData, resource as TSegmentSegmentFilter);
|
||||
resultPairs.push({
|
||||
@@ -716,34 +631,3 @@ export const evaluateSegment = async (
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// This function is used to check if the environment has a segment that uses actions
|
||||
export const getHasEnvironmentActionSegment = reactCache(
|
||||
(environmentId: string): Promise<boolean> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
const segments = await getSegments(environmentId);
|
||||
|
||||
if (!segments || !segments.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let hasEnvironmentActionSegment = false;
|
||||
|
||||
for (let segment of segments) {
|
||||
const hasActionFilter = JSON.stringify(segment.filters).includes(`"type":"action"`);
|
||||
if (hasActionFilter) {
|
||||
hasEnvironmentActionSegment = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return hasEnvironmentActionSegment;
|
||||
},
|
||||
[`getHasActionSegment-${environmentId}`],
|
||||
{
|
||||
tags: [segmentCache.tag.byEnvironmentId(environmentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
TActionMetric,
|
||||
TBaseFilters,
|
||||
TBaseOperator,
|
||||
TEvaluateSegmentUserAttributeData,
|
||||
TEvaluateSegmentUserData,
|
||||
TSegment,
|
||||
@@ -29,6 +27,7 @@ export const mockFilter3Resource1Id = "evvoaniy0hn7srea7x0yn4vv";
|
||||
// filter data:
|
||||
export const mockActionClassId = "zg7lojfwnk9ipajgeumfz96t";
|
||||
export const mockEmailValue = "example@example.com";
|
||||
export const mockEmailFailedValue = "";
|
||||
export const mockUserId = "random user id";
|
||||
export const mockDeviceTypeValue = "phone";
|
||||
|
||||
@@ -38,11 +37,24 @@ export const mockEvaluateSegmentUserAttributes: TEvaluateSegmentUserAttributeDat
|
||||
email: mockEmailValue,
|
||||
userId: mockUserId,
|
||||
};
|
||||
|
||||
export const mockEvaluateFailedSegmentUserAttributes: TEvaluateSegmentUserAttributeData = {
|
||||
email: mockEmailFailedValue,
|
||||
userId: mockUserId,
|
||||
};
|
||||
|
||||
export const mockEvaluateSegmentUserData: TEvaluateSegmentUserData = {
|
||||
personId: mockPersonId,
|
||||
environmentId: mockEnvironmentId,
|
||||
attributes: mockEvaluateSegmentUserAttributes,
|
||||
actionIds: [mockActionClassId],
|
||||
deviceType: "phone",
|
||||
userId: mockUserId,
|
||||
};
|
||||
|
||||
export const mockEvaluateFailedSegmentUserData: TEvaluateSegmentUserData = {
|
||||
personId: mockPersonId,
|
||||
environmentId: mockEnvironmentId,
|
||||
attributes: mockEvaluateFailedSegmentUserAttributes,
|
||||
deviceType: "phone",
|
||||
userId: mockUserId,
|
||||
};
|
||||
@@ -51,11 +63,7 @@ export const mockSegmentTitle = "Engaged Users with Specific Interests";
|
||||
export const mockSegmentDescription =
|
||||
"Segment targeting engaged users interested in specific topics and using mobile";
|
||||
|
||||
export const getMockSegmentFilters = (
|
||||
actionMetric: TActionMetric,
|
||||
actionValue: string | number,
|
||||
actionOperator: TBaseOperator
|
||||
): TBaseFilters => [
|
||||
export const getMockSegmentFilters = (): TBaseFilters => [
|
||||
{
|
||||
id: mockFilterGroupId,
|
||||
connector: null,
|
||||
@@ -107,22 +115,6 @@ export const getMockSegmentFilters = (
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: mockFilter3Id,
|
||||
connector: "and",
|
||||
resource: {
|
||||
id: mockFilter3Resource1Id,
|
||||
root: {
|
||||
type: "action",
|
||||
actionClassId: mockActionClassId,
|
||||
},
|
||||
value: actionValue,
|
||||
qualifier: {
|
||||
metric: actionMetric,
|
||||
operator: actionOperator,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const mockSegment: TSegment = {
|
||||
@@ -130,7 +122,7 @@ export const mockSegment: TSegment = {
|
||||
title: mockSegmentTitle,
|
||||
description: mockSegmentDescription,
|
||||
isPrivate: false,
|
||||
filters: getMockSegmentFilters("lastMonthCount", 5, "equals"),
|
||||
filters: getMockSegmentFilters(),
|
||||
environmentId: mockEnvironmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -141,7 +133,7 @@ export const mockSegmentCreateInput: TSegmentCreateInput = {
|
||||
title: mockSegmentTitle,
|
||||
description: mockSegmentDescription,
|
||||
isPrivate: false,
|
||||
filters: getMockSegmentFilters("lastMonthCount", 5, "equals"),
|
||||
filters: getMockSegmentFilters(),
|
||||
environmentId: mockEnvironmentId,
|
||||
surveyId: mockSurveyId,
|
||||
};
|
||||
@@ -150,7 +142,7 @@ export const mockSegmentUpdateInput: TSegmentUpdateInput = {
|
||||
title: mockSegmentTitle,
|
||||
description: mockSegmentDescription,
|
||||
isPrivate: false,
|
||||
filters: getMockSegmentFilters("lastMonthCount", 5, "greaterEqual"),
|
||||
filters: getMockSegmentFilters(),
|
||||
};
|
||||
|
||||
export const mockSegmentPrisma = {
|
||||
@@ -158,7 +150,7 @@ export const mockSegmentPrisma = {
|
||||
title: mockSegmentTitle,
|
||||
description: mockSegmentDescription,
|
||||
isPrivate: false,
|
||||
filters: getMockSegmentFilters("lastMonthCount", 5, "equals"),
|
||||
filters: getMockSegmentFilters(),
|
||||
environmentId: mockEnvironmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
mockDeleteSegmentId,
|
||||
mockDeleteSegmentPrisma,
|
||||
mockEnvironmentId,
|
||||
mockEvaluateFailedSegmentUserData,
|
||||
mockEvaluateSegmentUserData,
|
||||
mockSegment,
|
||||
mockSegmentCreateInput,
|
||||
@@ -27,85 +28,27 @@ import {
|
||||
updateSegment,
|
||||
} from "../service";
|
||||
|
||||
const addOrSubractDays = (date: Date, number: number) => {
|
||||
return new Date(new Date().setDate(date.getDate() - number));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
prisma.segment.findUnique.mockResolvedValue(mockSegmentPrisma);
|
||||
prisma.segment.findMany.mockResolvedValue([mockSegmentPrisma]);
|
||||
prisma.segment.update.mockResolvedValue({
|
||||
...mockSegmentPrisma,
|
||||
filters: getMockSegmentFilters("lastMonthCount", 5, "greaterEqual"),
|
||||
filters: getMockSegmentFilters(),
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for evaluateSegment service", () => {
|
||||
describe("Happy Path", () => {
|
||||
it("Returns true when the user meets the segment criteria", async () => {
|
||||
prisma.action.count.mockResolvedValue(4);
|
||||
const result = await evaluateSegment(
|
||||
mockEvaluateSegmentUserData,
|
||||
getMockSegmentFilters("lastQuarterCount", 5, "lessThan")
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("Calculates the action count for the last month", async () => {
|
||||
prisma.action.count.mockResolvedValue(0);
|
||||
const result = await evaluateSegment(
|
||||
mockEvaluateSegmentUserData,
|
||||
getMockSegmentFilters("lastMonthCount", 5, "lessThan")
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("Calculates the action count for the last week", async () => {
|
||||
prisma.action.count.mockResolvedValue(6);
|
||||
const result = await evaluateSegment(
|
||||
mockEvaluateSegmentUserData,
|
||||
getMockSegmentFilters("lastWeekCount", 5, "greaterEqual")
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("Calculates the total occurences of action", async () => {
|
||||
prisma.action.count.mockResolvedValue(6);
|
||||
const result = await evaluateSegment(
|
||||
mockEvaluateSegmentUserData,
|
||||
getMockSegmentFilters("occuranceCount", 5, "greaterEqual")
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("Calculates the last occurence days ago of action", async () => {
|
||||
prisma.action.findFirst.mockResolvedValue({ createdAt: addOrSubractDays(new Date(), 5) } as any);
|
||||
|
||||
const result = await evaluateSegment(
|
||||
mockEvaluateSegmentUserData,
|
||||
getMockSegmentFilters("lastOccurranceDaysAgo", 0, "greaterEqual")
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("Calculates the first occurence days ago of action", async () => {
|
||||
prisma.action.findFirst.mockResolvedValue({ createdAt: addOrSubractDays(new Date(), 5) } as any);
|
||||
|
||||
const result = await evaluateSegment(
|
||||
mockEvaluateSegmentUserData,
|
||||
getMockSegmentFilters("firstOccurranceDaysAgo", 6, "lessThan")
|
||||
);
|
||||
// prisma.action.count.mockResolvedValue(4);
|
||||
const result = await evaluateSegment(mockEvaluateSegmentUserData, getMockSegmentFilters());
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
it("Returns false when the user does not meet the segment criteria", async () => {
|
||||
prisma.action.count.mockResolvedValue(0);
|
||||
const result = await evaluateSegment(
|
||||
mockEvaluateSegmentUserData,
|
||||
getMockSegmentFilters("lastQuarterCount", 5, "greaterThan")
|
||||
);
|
||||
const result = await evaluateSegment(mockEvaluateFailedSegmentUserData, getMockSegmentFilters());
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -219,7 +162,7 @@ describe("Tests for updateSegment service", () => {
|
||||
const result = await updateSegment(mockSegmentId, mockSegmentUpdateInput);
|
||||
expect(result).toEqual({
|
||||
...mockSegment,
|
||||
filters: getMockSegmentFilters("lastMonthCount", 5, "greaterEqual"),
|
||||
filters: getMockSegmentFilters(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import {
|
||||
TActionMetric,
|
||||
TAllOperators,
|
||||
TAttributeOperator,
|
||||
TBaseFilter,
|
||||
TBaseFilters,
|
||||
TDeviceOperator,
|
||||
TSegment,
|
||||
TSegmentActionFilter,
|
||||
TSegmentAttributeFilter,
|
||||
TSegmentConnector,
|
||||
TSegmentDeviceFilter,
|
||||
@@ -92,25 +90,6 @@ export const convertOperatorToTitle = (operator: TAllOperators) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const convertMetricToText = (metric: TActionMetric) => {
|
||||
switch (metric) {
|
||||
case "lastQuarterCount":
|
||||
return "Last quarter (Count)";
|
||||
case "lastMonthCount":
|
||||
return "Last month (Count)";
|
||||
case "lastWeekCount":
|
||||
return "Last week (Count)";
|
||||
case "occuranceCount":
|
||||
return "Occurance (Count)";
|
||||
case "lastOccurranceDaysAgo":
|
||||
return "Last occurrance (Days ago)";
|
||||
case "firstOccurranceDaysAgo":
|
||||
return "First occurrance (Days ago)";
|
||||
default:
|
||||
return metric;
|
||||
}
|
||||
};
|
||||
|
||||
export const addFilterBelow = (group: TBaseFilters, resourceId: string, filter: TBaseFilter) => {
|
||||
for (let i = 0; i < group.length; i++) {
|
||||
const { resource } = group[i];
|
||||
@@ -410,40 +389,6 @@ export const updatePersonIdentifierInFilter = (
|
||||
}
|
||||
};
|
||||
|
||||
export const updateActionClassIdInFilter = (
|
||||
group: TBaseFilters,
|
||||
filterId: string,
|
||||
newActionClassId: string
|
||||
) => {
|
||||
for (let i = 0; i < group.length; i++) {
|
||||
const { resource } = group[i];
|
||||
|
||||
if (isResourceFilter(resource)) {
|
||||
if (resource.id === filterId) {
|
||||
(resource as TSegmentActionFilter).root.actionClassId = newActionClassId;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
updateActionClassIdInFilter(resource, filterId, newActionClassId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const updateMetricInFilter = (group: TBaseFilters, filterId: string, newMetric: TActionMetric) => {
|
||||
for (let i = 0; i < group.length; i++) {
|
||||
const { resource } = group[i];
|
||||
|
||||
if (isResourceFilter(resource)) {
|
||||
if (resource.id === filterId) {
|
||||
(resource as TSegmentActionFilter).qualifier.metric = newMetric;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
updateMetricInFilter(resource, filterId, newMetric);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSegmentIdInFilter = (group: TBaseFilters, filterId: string, newSegmentId: string) => {
|
||||
for (let i = 0; i < group.length; i++) {
|
||||
const { resource } = group[i];
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
ZSurvey,
|
||||
ZSurveyCreateInput,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getActionsByPersonId } from "../action/service";
|
||||
import { actionClassCache } from "../actionClass/cache";
|
||||
import { getActionClasses } from "../actionClass/service";
|
||||
import { attributeCache } from "../attribute/cache";
|
||||
@@ -1189,11 +1188,6 @@ export const getSyncSurveys = reactCache(
|
||||
return surveys;
|
||||
}
|
||||
|
||||
const personActions = await getActionsByPersonId(person.id);
|
||||
const personActionClassIds = Array.from(
|
||||
new Set(personActions?.map((action) => action.actionClass?.id ?? ""))
|
||||
);
|
||||
|
||||
const attributes = await getAttributes(person.id);
|
||||
const personUserId = person.userId;
|
||||
|
||||
@@ -1209,7 +1203,6 @@ export const getSyncSurveys = reactCache(
|
||||
const result = await evaluateSegment(
|
||||
{
|
||||
attributes: attributes ?? {},
|
||||
actionIds: personActionClassIds,
|
||||
deviceType,
|
||||
environmentId,
|
||||
personId: person.id,
|
||||
|
||||
@@ -2691,7 +2691,18 @@ const NPS = (): TTemplate => {
|
||||
};
|
||||
|
||||
const customerSatisfactionScore = (): TTemplate => {
|
||||
const reusableQuestionIds = [createId(), createId(), createId()];
|
||||
const reusableQuestionIds = [
|
||||
createId(),
|
||||
createId(),
|
||||
createId(),
|
||||
createId(),
|
||||
createId(),
|
||||
createId(),
|
||||
createId(),
|
||||
createId(),
|
||||
createId(),
|
||||
createId(),
|
||||
];
|
||||
return {
|
||||
name: "Customer Satisfaction Score (CSAT)",
|
||||
role: "customerSuccess",
|
||||
@@ -2705,39 +2716,12 @@ const customerSatisfactionScore = (): TTemplate => {
|
||||
{
|
||||
id: reusableQuestionIds[0],
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[0],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isLessThanOrEqual",
|
||||
rightOperand: {
|
||||
type: "static",
|
||||
value: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: reusableQuestionIds[2],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
headline: { default: "How satisfied are you with your {{productName}} experience?" },
|
||||
range: 10,
|
||||
scale: "number",
|
||||
headline: {
|
||||
default:
|
||||
"How likely is it that you would recommend this {{productName}} to a friend or colleague?",
|
||||
},
|
||||
required: true,
|
||||
lowerLabel: { default: "Not satisfied" },
|
||||
upperLabel: { default: "Very satisfied" },
|
||||
@@ -2745,42 +2729,129 @@ const customerSatisfactionScore = (): TTemplate => {
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[1],
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[1],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isSubmitted",
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: surveyDefault.endings[0].id,
|
||||
},
|
||||
],
|
||||
},
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Overall, how satisfied or dissatisfied are you with our {{productName}}" },
|
||||
subheader: { default: "Please select one:" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: createId(), label: { default: "Very satisfied" } },
|
||||
{ id: createId(), label: { default: "Somewhat satisfied" } },
|
||||
{ id: createId(), label: { default: "Neither satisfied nor dissatisfied" } },
|
||||
{ id: createId(), label: { default: "Somewhat dissatisfied" } },
|
||||
{ id: createId(), label: { default: "Very dissatisfied" } },
|
||||
],
|
||||
headline: { default: "Lovely! Is there anything we can do to improve your experience?" },
|
||||
required: false,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[2],
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
headline: {
|
||||
default: "Which of the following words would you use to describe our {{productName}}?",
|
||||
},
|
||||
subheader: { default: "Select all that apply:" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: createId(), label: { default: "Reliable" } },
|
||||
{ id: createId(), label: { default: "High quality" } },
|
||||
{ id: createId(), label: { default: "Overpriced" } },
|
||||
{ id: createId(), label: { default: "Impractical" } },
|
||||
{ id: createId(), label: { default: "Useful" } },
|
||||
{ id: createId(), label: { default: "Ineffective" } },
|
||||
{ id: createId(), label: { default: "Unique" } },
|
||||
{ id: createId(), label: { default: "Poor quality" } },
|
||||
{ id: createId(), label: { default: "Good value for money" } },
|
||||
{ id: createId(), label: { default: "Unreliable" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[3],
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "How well do our {{productName}} meet your needs?" },
|
||||
subheader: { default: "Select one option:" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: createId(), label: { default: "Extremely well" } },
|
||||
{ id: createId(), label: { default: "Very well" } },
|
||||
{ id: createId(), label: { default: "Somewhat well" } },
|
||||
{ id: createId(), label: { default: "Not so well" } },
|
||||
{ id: createId(), label: { default: "Not at all well" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[4],
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "How would you rate the quality of the {{productName}}?" },
|
||||
subheader: { default: "Select one option:" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: createId(), label: { default: "Very high quality" } },
|
||||
{ id: createId(), label: { default: "High quality" } },
|
||||
{ id: createId(), label: { default: "Low quality" } },
|
||||
{ id: createId(), label: { default: "Very low quality" } },
|
||||
{ id: createId(), label: { default: "Neither high nor low" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[5],
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "How would you rate the value for money of the {{productName}}?" },
|
||||
subheader: { default: "Please select one:" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: createId(), label: { default: "Excellent" } },
|
||||
{ id: createId(), label: { default: "Above average" } },
|
||||
{ id: createId(), label: { default: "Average" } },
|
||||
{ id: createId(), label: { default: "Below average" } },
|
||||
{ id: createId(), label: { default: "Poor" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[6],
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "How responsive have we been to your questions about our services?" },
|
||||
subheader: { default: "Select one option:" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: createId(), label: { default: "Extremely responsive" } },
|
||||
{ id: createId(), label: { default: "Very responsive" } },
|
||||
{ id: createId(), label: { default: "Somewhat responsive" } },
|
||||
{ id: createId(), label: { default: "Not so responsive" } },
|
||||
{ id: createId(), label: { default: "Not at all responsive" } },
|
||||
{ id: createId(), label: { default: "Not applicable" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[7],
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "How long have you been a customer of {{productName}}?" },
|
||||
subheader: { default: "Select one option:" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: createId(), label: { default: "This is my first purchase" } },
|
||||
{ id: createId(), label: { default: "Less than six months" } },
|
||||
{ id: createId(), label: { default: "Six months to a year" } },
|
||||
{ id: createId(), label: { default: "1 - 2 years" } },
|
||||
{ id: createId(), label: { default: "3 or more years" } },
|
||||
{ id: createId(), label: { default: "I haven't made a purchase yet" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[8],
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "How likely are you to purchase any of our {{productName}} again ?" },
|
||||
subheader: { default: "Select one option:" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: createId(), label: { default: "Extremely likely" } },
|
||||
{ id: createId(), label: { default: "Very likely" } },
|
||||
{ id: createId(), label: { default: "Somewhat likely" } },
|
||||
{ id: createId(), label: { default: "Not so likely" } },
|
||||
{ id: createId(), label: { default: "Not at all likely" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[9],
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Ugh, sorry! Is there anything we can do to improve your experience?" },
|
||||
headline: { default: "Do you have any other comments, questions or concerns?" },
|
||||
required: false,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
@@ -5185,9 +5256,9 @@ export const getExampleAppSurveyTemplate = (
|
||||
({
|
||||
...question,
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "App successfully connected 🥳" },
|
||||
headline: { default: "App successfully connected" },
|
||||
html: {
|
||||
default: "You're all set up. Create your own survey for your app users 👇",
|
||||
default: "You're all set up. Create your own survey for your app users.",
|
||||
},
|
||||
buttonLabel: { default: "Let's do it!" },
|
||||
buttonExternal: true,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ArrowUpFromLineIcon,
|
||||
CalendarDaysIcon,
|
||||
CheckIcon,
|
||||
ContactIcon,
|
||||
FileDigitIcon,
|
||||
FileType2Icon,
|
||||
Grid3X3Icon,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
TSurveyCTAQuestion,
|
||||
TSurveyCalQuestion,
|
||||
TSurveyConsentQuestion,
|
||||
TSurveyContactInfoQuestion,
|
||||
TSurveyDateQuestion,
|
||||
TSurveyFileUploadQuestion,
|
||||
TSurveyMatrixQuestion,
|
||||
@@ -221,14 +223,28 @@ export const questionTypes: TQuestion[] = [
|
||||
icon: HomeIcon,
|
||||
preset: {
|
||||
headline: { default: "Where do you live?" },
|
||||
isAddressLine1Required: false,
|
||||
isAddressLine2Required: false,
|
||||
isCityRequired: false,
|
||||
isStateRequired: false,
|
||||
isZipRequired: false,
|
||||
isCountryRequired: false,
|
||||
addressLine1: { show: true, required: true },
|
||||
addressLine2: { show: true, required: true },
|
||||
city: { show: true, required: true },
|
||||
state: { show: true, required: true },
|
||||
zip: { show: true, required: true },
|
||||
country: { show: true, required: true },
|
||||
} as Partial<TSurveyAddressQuestion>,
|
||||
},
|
||||
{
|
||||
id: QuestionId.ContactInfo,
|
||||
label: "Contact Info",
|
||||
description: "Allow respondents to provide their contact info",
|
||||
icon: ContactIcon,
|
||||
preset: {
|
||||
headline: { default: "Contact Info" },
|
||||
firstName: { show: true, required: true },
|
||||
lastName: { show: true, required: true },
|
||||
email: { show: true, required: true },
|
||||
phone: { show: true, required: true },
|
||||
company: { show: true, required: true },
|
||||
} as Partial<TSurveyContactInfoQuestion>,
|
||||
},
|
||||
];
|
||||
|
||||
export const CXQuestionTypes = questionTypes.filter((questionType) => {
|
||||
|
||||
19
packages/surveys/src/components/general/Input.tsx
Normal file
19
packages/surveys/src/components/general/Input.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HTMLAttributes } from "preact/compat";
|
||||
|
||||
export interface InputProps extends HTMLAttributes<HTMLInputElement> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Input = ({ className, ...props }: InputProps) => {
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
className={cn(
|
||||
"focus:fb-border-brand fb-bg-input-bg fb-flex fb-w-full fb-border fb-border-border fb-rounded-custom fb-px-3 fb-py-2 fb-text-sm fb-text-subheading placeholder:fb-text-placeholder focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50 dark:fb-border-slate-500 dark:fb-text-slate-300",
|
||||
className ?? ""
|
||||
)}
|
||||
dir="auto"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,7 @@ export const LoadingSpinner = ({ className }: { className?: string }) => {
|
||||
data-testid="loading-spinner"
|
||||
className={cn("fb-flex fb-h-full fb-w-full fb-items-center fb-justify-center", className ?? "")}>
|
||||
<svg
|
||||
className="fb-m-2 fb-h-6 fb-w-6 fb-animate-spin fb-text-slate-700"
|
||||
className="fb-m-2 fb-h-6 fb-w-6 fb-animate-spin fb-text-brand"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AddressQuestion } from "@/components/questions/AddressQuestion";
|
||||
import { CTAQuestion } from "@/components/questions/CTAQuestion";
|
||||
import { CalQuestion } from "@/components/questions/CalQuestion";
|
||||
import { ConsentQuestion } from "@/components/questions/ConsentQuestion";
|
||||
import { ContactInfoQuestion } from "@/components/questions/ContactInfoQuestion";
|
||||
import { DateQuestion } from "@/components/questions/DateQuestion";
|
||||
import { FileUploadQuestion } from "@/components/questions/FileUploadQuestion";
|
||||
import { MatrixQuestion } from "@/components/questions/MatrixQuestion";
|
||||
@@ -280,7 +281,6 @@ export const QuestionConditional = ({
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentQuestionId}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.Ranking ? (
|
||||
@@ -298,5 +298,19 @@ export const QuestionConditional = ({
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentQuestionId}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.ContactInfo ? (
|
||||
<ContactInfoQuestion
|
||||
question={question}
|
||||
value={Array.isArray(value) ? value : undefined}
|
||||
onChange={onChange}
|
||||
onSubmit={onSubmit}
|
||||
onBack={onBack}
|
||||
isFirstQuestion={isFirstQuestion}
|
||||
isLastQuestion={isLastQuestion}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
currentQuestionId={currentQuestionId}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { BackButton } from "@/components/buttons/BackButton";
|
||||
import { SubmitButton } from "@/components/buttons/SubmitButton";
|
||||
import { Headline } from "@/components/general/Headline";
|
||||
import { Input } from "@/components/general/Input";
|
||||
import { QuestionMedia } from "@/components/general/QuestionMedia";
|
||||
import { Subheader } from "@/components/general/Subheader";
|
||||
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
|
||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { useMemo, useRef, useState } from "preact/hooks";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyAddressQuestion } from "@formbricks/types/surveys/types";
|
||||
@@ -18,11 +19,9 @@ interface AddressQuestionProps {
|
||||
onBack: () => void;
|
||||
isFirstQuestion: boolean;
|
||||
isLastQuestion: boolean;
|
||||
autoFocus?: boolean;
|
||||
languageCode: string;
|
||||
ttc: TResponseTtc;
|
||||
setTtc: (ttc: TResponseTtc) => void;
|
||||
autoFocusEnabled: boolean;
|
||||
currentQuestionId: string;
|
||||
}
|
||||
|
||||
@@ -37,11 +36,9 @@ export const AddressQuestion = ({
|
||||
languageCode,
|
||||
ttc,
|
||||
setTtc,
|
||||
autoFocusEnabled,
|
||||
currentQuestionId,
|
||||
}: AddressQuestionProps) => {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const [hasFilled, setHasFilled] = useState(false);
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
|
||||
@@ -50,85 +47,62 @@ export const AddressQuestion = ({
|
||||
return Array.isArray(value) ? value : ["", "", "", "", "", ""];
|
||||
}, [value]);
|
||||
|
||||
const handleInputChange = (inputValue: string, index: number) => {
|
||||
const updatedValue = [...safeValue];
|
||||
updatedValue[index] = inputValue.trimStart();
|
||||
onChange({ [question.id]: updatedValue });
|
||||
const fields = [
|
||||
{
|
||||
id: "addressLine1",
|
||||
placeholder: "Address Line 1",
|
||||
...question.addressLine1,
|
||||
},
|
||||
{
|
||||
id: "addressLine2",
|
||||
placeholder: "Address Line 2",
|
||||
...question.addressLine2,
|
||||
},
|
||||
{
|
||||
id: "city",
|
||||
placeholder: "City",
|
||||
...question.city,
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
placeholder: "State",
|
||||
...question.state,
|
||||
},
|
||||
{
|
||||
id: "zip",
|
||||
placeholder: "Zip",
|
||||
...question.zip,
|
||||
},
|
||||
{
|
||||
id: "country",
|
||||
placeholder: "Country",
|
||||
...question.country,
|
||||
},
|
||||
];
|
||||
|
||||
const handleChange = (fieldId: string, fieldValue: string) => {
|
||||
const newValue = fields.map((field) => {
|
||||
if (field.id === fieldId) {
|
||||
return fieldValue;
|
||||
}
|
||||
const existingValue = safeValue?.[fields.findIndex((f) => f.id === field.id)] || "";
|
||||
return field.show ? existingValue : "";
|
||||
});
|
||||
onChange({ [question.id]: newValue });
|
||||
};
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
const updatedTtc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtc);
|
||||
const containsAllEmptyStrings = value?.length === 6 && value.every((item) => item.trim() === "");
|
||||
const containsAllEmptyStrings = safeValue?.length === 6 && safeValue.every((item) => item.trim() === "");
|
||||
if (containsAllEmptyStrings) {
|
||||
onSubmit({ [question.id]: [] }, updatedTtc);
|
||||
} else {
|
||||
onSubmit({ [question.id]: value ?? [] }, updatedTtc);
|
||||
onSubmit({ [question.id]: safeValue ?? [] }, updatedTtc);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const filled = safeValue.some((val) => val.trim().length > 0);
|
||||
setHasFilled(filled);
|
||||
}, [value, safeValue]);
|
||||
|
||||
const inputConfig = [
|
||||
{
|
||||
name: "address-line1",
|
||||
placeholder: "Address Line 1",
|
||||
required: question.required
|
||||
? hasFilled
|
||||
? question.isAddressLine1Required
|
||||
: true
|
||||
: hasFilled
|
||||
? question.isAddressLine1Required
|
||||
: false,
|
||||
},
|
||||
{
|
||||
name: "address-line2",
|
||||
placeholder: "Address Line 2",
|
||||
required: question.required
|
||||
? question.isAddressLine2Required
|
||||
: hasFilled
|
||||
? question.isAddressLine2Required
|
||||
: false,
|
||||
},
|
||||
{
|
||||
name: "address-level2",
|
||||
placeholder: "City / Town",
|
||||
required: question.required ? question.isCityRequired : hasFilled ? question.isCityRequired : false,
|
||||
},
|
||||
{
|
||||
name: "address-level1",
|
||||
placeholder: "State / Region",
|
||||
required: question.required ? question.isStateRequired : hasFilled ? question.isStateRequired : false,
|
||||
},
|
||||
{
|
||||
name: "postal-code",
|
||||
placeholder: "ZIP / Post Code",
|
||||
required: question.required ? question.isZipRequired : hasFilled ? question.isZipRequired : false,
|
||||
},
|
||||
{
|
||||
name: "country-name",
|
||||
placeholder: "Country",
|
||||
required: question.required
|
||||
? question.isCountryRequired
|
||||
: hasFilled
|
||||
? question.isCountryRequired
|
||||
: false,
|
||||
},
|
||||
];
|
||||
|
||||
const addressTextRef = useCallback(
|
||||
(currentElement: HTMLInputElement | null) => {
|
||||
if (question.id && currentElement && autoFocusEnabled) {
|
||||
currentElement.focus();
|
||||
}
|
||||
},
|
||||
[question.id, autoFocusEnabled]
|
||||
);
|
||||
|
||||
return (
|
||||
<form key={question.id} onSubmit={handleSubmit} className="fb-w-full" ref={formRef}>
|
||||
<ScrollableContainer>
|
||||
@@ -143,24 +117,39 @@ export const AddressQuestion = ({
|
||||
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
|
||||
questionId={question.id}
|
||||
/>
|
||||
<div className="fb-mt-4 fb-space-y-2">
|
||||
{inputConfig.map(({ name, placeholder, required }, index) => (
|
||||
<input
|
||||
ref={index === 0 ? addressTextRef : null}
|
||||
dir="auto"
|
||||
key={index}
|
||||
name={name}
|
||||
autoComplete={name}
|
||||
id={`${question.id}-${index}`}
|
||||
placeholder={placeholder}
|
||||
tabIndex={index + 1}
|
||||
required={required}
|
||||
value={safeValue[index] || ""}
|
||||
onInput={(e) => handleInputChange(e.currentTarget.value, index)}
|
||||
autoFocus={autoFocusEnabled && index === 0}
|
||||
className="fb-border-border focus:fb-border-brand placeholder:fb-text-placeholder fb-text-subheading fb-bg-input-bg fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm sm:fb-text-sm"
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className={`fb-flex fb-flex-col fb-space-y-2 fb-mt-4 fb-w-full`}>
|
||||
{fields.map((field, index) => {
|
||||
const isFieldRequired = () => {
|
||||
if (field.required) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// if all fields are optional and the question is required, then the fields should be required
|
||||
if (
|
||||
fields.filter((field) => field.show).every((field) => !field.required) &&
|
||||
question.required
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
field.show && (
|
||||
<Input
|
||||
key={field.id}
|
||||
placeholder={isFieldRequired() ? `${field.placeholder}*` : field.placeholder}
|
||||
required={isFieldRequired()}
|
||||
value={safeValue?.[index] || ""}
|
||||
className="fb-py-3"
|
||||
type={field.id === "email" ? "email" : "text"}
|
||||
onChange={(e) => handleChange(field.id, e?.currentTarget?.value ?? "")}
|
||||
/>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollableContainer>
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { BackButton } from "@/components/buttons/BackButton";
|
||||
import { SubmitButton } from "@/components/buttons/SubmitButton";
|
||||
import { Headline } from "@/components/general/Headline";
|
||||
import { Input } from "@/components/general/Input";
|
||||
import { QuestionMedia } from "@/components/general/QuestionMedia";
|
||||
import { Subheader } from "@/components/general/Subheader";
|
||||
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
|
||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
import { useMemo, useRef, useState } from "preact/hooks";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyContactInfoQuestion } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface ContactInfoQuestionProps {
|
||||
question: TSurveyContactInfoQuestion;
|
||||
value?: string[];
|
||||
onChange: (responseData: TResponseData) => void;
|
||||
onSubmit: (data: TResponseData, ttc: TResponseTtc) => void;
|
||||
onBack: () => void;
|
||||
isFirstQuestion: boolean;
|
||||
isLastQuestion: boolean;
|
||||
autoFocus?: boolean;
|
||||
languageCode: string;
|
||||
ttc: TResponseTtc;
|
||||
setTtc: (ttc: TResponseTtc) => void;
|
||||
currentQuestionId: string;
|
||||
}
|
||||
|
||||
export const ContactInfoQuestion = ({
|
||||
question,
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onBack,
|
||||
isFirstQuestion,
|
||||
isLastQuestion,
|
||||
languageCode,
|
||||
ttc,
|
||||
setTtc,
|
||||
currentQuestionId,
|
||||
}: ContactInfoQuestionProps) => {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
|
||||
|
||||
const safeValue = useMemo(() => {
|
||||
return Array.isArray(value) ? value : ["", "", "", "", ""];
|
||||
}, [value]);
|
||||
|
||||
const fields = [
|
||||
{
|
||||
id: "firstName",
|
||||
placeholder: "First Name",
|
||||
...question.firstName,
|
||||
},
|
||||
{
|
||||
id: "lastName",
|
||||
placeholder: "Last Name",
|
||||
...question.lastName,
|
||||
},
|
||||
{
|
||||
id: "email",
|
||||
placeholder: "Email",
|
||||
...question.email,
|
||||
},
|
||||
{
|
||||
id: "phone",
|
||||
placeholder: "Phone",
|
||||
...question.phone,
|
||||
},
|
||||
{
|
||||
id: "company",
|
||||
placeholder: "Company",
|
||||
...question.company,
|
||||
},
|
||||
];
|
||||
|
||||
const handleChange = (fieldId: string, fieldValue: string) => {
|
||||
const newValue = fields.map((field) => {
|
||||
if (field.id === fieldId) {
|
||||
return fieldValue;
|
||||
}
|
||||
const existingValue = safeValue?.[fields.findIndex((f) => f.id === field.id)] || "";
|
||||
return field.show ? existingValue : "";
|
||||
});
|
||||
onChange({ [question.id]: newValue });
|
||||
};
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
const updatedTtc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtc);
|
||||
const containsAllEmptyStrings = safeValue?.length === 5 && safeValue.every((item) => item.trim() === "");
|
||||
if (containsAllEmptyStrings) {
|
||||
onSubmit({ [question.id]: [] }, updatedTtc);
|
||||
} else {
|
||||
onSubmit({ [question.id]: safeValue ?? [] }, updatedTtc);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form key={question.id} onSubmit={handleSubmit} className="fb-w-full" ref={formRef}>
|
||||
<ScrollableContainer>
|
||||
<div>
|
||||
{isMediaAvailable && <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />}
|
||||
<Headline
|
||||
headline={getLocalizedValue(question.headline, languageCode)}
|
||||
questionId={question.id}
|
||||
required={question.required}
|
||||
/>
|
||||
<Subheader
|
||||
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
|
||||
questionId={question.id}
|
||||
/>
|
||||
|
||||
<div className={`fb-flex fb-flex-col fb-space-y-2 fb-mt-4 fb-w-full`}>
|
||||
{fields.map((field, index) => {
|
||||
const isFieldRequired = () => {
|
||||
if (field.required) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// if all fields are optional and the question is required, then the fields should be required
|
||||
if (
|
||||
fields.filter((field) => field.show).every((field) => !field.required) &&
|
||||
question.required
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
let inputType = "text";
|
||||
if (field.id === "email") {
|
||||
inputType = "email";
|
||||
} else if (field.id === "phone") {
|
||||
inputType = "number";
|
||||
}
|
||||
|
||||
return (
|
||||
field.show && (
|
||||
<Input
|
||||
key={field.id}
|
||||
placeholder={isFieldRequired() ? `${field.placeholder}*` : field.placeholder}
|
||||
required={isFieldRequired()}
|
||||
value={safeValue?.[index] || ""}
|
||||
className="fb-py-3"
|
||||
type={inputType}
|
||||
onChange={(e) => handleChange(field.id, e?.currentTarget?.value ?? "")}
|
||||
/>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollableContainer>
|
||||
|
||||
<div className="fb-flex fb-w-full fb-justify-between fb-px-6 fb-py-4">
|
||||
{!isFirstQuestion && (
|
||||
<BackButton
|
||||
tabIndex={8}
|
||||
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
|
||||
onClick={() => {
|
||||
const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedttc);
|
||||
onBack();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div></div>
|
||||
<SubmitButton
|
||||
tabIndex={7}
|
||||
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
|
||||
isLastQuestion={isLastQuestion}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { ZActionClass } from "./action-classes";
|
||||
import { ZId } from "./common";
|
||||
|
||||
export const ZAction = z.object({
|
||||
id: ZId,
|
||||
createdAt: z.date(),
|
||||
personId: ZId,
|
||||
properties: z.record(z.string()),
|
||||
actionClass: ZActionClass.nullable(),
|
||||
});
|
||||
|
||||
export type TAction = z.infer<typeof ZAction>;
|
||||
|
||||
export const ZActionInput = z.object({
|
||||
environmentId: ZId,
|
||||
userId: ZId,
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export type TActionInput = z.infer<typeof ZActionInput>;
|
||||
@@ -134,6 +134,10 @@ const ZResponseFilterCriteriaMatrix = z.object({
|
||||
value: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaFilledOut = z.object({
|
||||
op: z.literal("filledOut"),
|
||||
});
|
||||
|
||||
export const ZResponseFilterCriteria = z.object({
|
||||
finished: z.boolean().optional(),
|
||||
createdAt: z
|
||||
@@ -171,6 +175,7 @@ export const ZResponseFilterCriteria = z.object({
|
||||
ZResponseFilterCriteriaDataNotUploaded,
|
||||
ZResponseFilterCriteriaDataBooked,
|
||||
ZResponseFilterCriteriaMatrix,
|
||||
ZResponseFilterCriteriaFilledOut,
|
||||
])
|
||||
)
|
||||
.optional(),
|
||||
|
||||
@@ -31,17 +31,6 @@ export const ATTRIBUTE_OPERATORS = [
|
||||
// but we might want to add more operators in the future, so we keep it separated
|
||||
export const PERSON_OPERATORS = ATTRIBUTE_OPERATORS;
|
||||
|
||||
// A metric is always only associated with an action filter
|
||||
// Metrics are used to evaluate the value of an action filter, from the database
|
||||
export const ACTION_METRICS = [
|
||||
"lastQuarterCount",
|
||||
"lastMonthCount",
|
||||
"lastWeekCount",
|
||||
"occuranceCount",
|
||||
"lastOccurranceDaysAgo",
|
||||
"firstOccurranceDaysAgo",
|
||||
] as const;
|
||||
|
||||
// operators for segment filters
|
||||
export const SEGMENT_OPERATORS = ["userIsIn", "userIsNotIn"] as const;
|
||||
|
||||
@@ -65,18 +54,14 @@ export type TDeviceOperator = z.infer<typeof ZDeviceOperator>;
|
||||
|
||||
export type TAllOperators = (typeof ALL_OPERATORS)[number];
|
||||
|
||||
export const ZActionMetric = z.enum(ACTION_METRICS);
|
||||
export type TActionMetric = z.infer<typeof ZActionMetric>;
|
||||
|
||||
export const ZSegmentFilterValue = z.union([z.string(), z.number()]);
|
||||
export type TSegmentFilterValue = z.infer<typeof ZSegmentFilterValue>;
|
||||
|
||||
// the type of the root of a filter
|
||||
export const ZSegmentFilterRootType = z.enum(["attribute", "action", "segment", "device", "person"]);
|
||||
export const ZSegmentFilterRootType = z.enum(["attribute", "segment", "device", "person"]);
|
||||
|
||||
// Root of the filter, this defines the type of the filter and the metadata associated with it
|
||||
// For example, if the root is "attribute", the attributeClassName is required
|
||||
// if the root is "action", the actionClassId is required.
|
||||
export const ZSegmentFilterRoot = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal(ZSegmentFilterRootType.Enum.attribute),
|
||||
@@ -86,10 +71,6 @@ export const ZSegmentFilterRoot = z.discriminatedUnion("type", [
|
||||
type: z.literal(ZSegmentFilterRootType.Enum.person),
|
||||
userId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(ZSegmentFilterRootType.Enum.action),
|
||||
actionClassId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(ZSegmentFilterRootType.Enum.segment),
|
||||
segmentId: z.string(),
|
||||
@@ -101,8 +82,6 @@ export const ZSegmentFilterRoot = z.discriminatedUnion("type", [
|
||||
]);
|
||||
|
||||
// Each filter has a qualifier, which usually contains the operator for evaluating the filter.
|
||||
// Only in the case of action filters, the metric is also included in the qualifier
|
||||
|
||||
// Attribute filter -> root will always have type "attribute"
|
||||
export const ZSegmentAttributeFilter = z.object({
|
||||
id: z.string().cuid2(),
|
||||
@@ -131,41 +110,6 @@ export const ZSegmentPersonFilter = z.object({
|
||||
});
|
||||
export type TSegmentPersonFilter = z.infer<typeof ZSegmentPersonFilter>;
|
||||
|
||||
// Action filter -> root will always have type "action"
|
||||
// Action filters also have the metric along with the operator in the qualifier of the filter
|
||||
export const ZSegmentActionFilter = z
|
||||
.object({
|
||||
id: z.string().cuid2(),
|
||||
root: z.object({
|
||||
type: z.literal("action"),
|
||||
actionClassId: z.string(),
|
||||
}),
|
||||
value: ZSegmentFilterValue,
|
||||
qualifier: z.object({
|
||||
metric: z.enum(ACTION_METRICS),
|
||||
operator: ZBaseOperator,
|
||||
}),
|
||||
})
|
||||
.refine(
|
||||
(actionFilter) => {
|
||||
const { value } = actionFilter;
|
||||
|
||||
// if the value is not type of number, it's invalid
|
||||
|
||||
const isValueNumber = typeof value === "number";
|
||||
|
||||
if (!isValueNumber) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Value must be a number for action filters",
|
||||
}
|
||||
);
|
||||
export type TSegmentActionFilter = z.infer<typeof ZSegmentActionFilter>;
|
||||
|
||||
// Segment filter -> root will always have type "segment"
|
||||
export const ZSegmentSegmentFilter = z.object({
|
||||
id: z.string().cuid2(),
|
||||
@@ -197,28 +141,8 @@ export type TSegmentDeviceFilter = z.infer<typeof ZSegmentDeviceFilter>;
|
||||
|
||||
// A segment filter is a union of all the different filter types
|
||||
export const ZSegmentFilter = z
|
||||
.union([
|
||||
ZSegmentActionFilter,
|
||||
ZSegmentAttributeFilter,
|
||||
ZSegmentPersonFilter,
|
||||
ZSegmentSegmentFilter,
|
||||
ZSegmentDeviceFilter,
|
||||
])
|
||||
.union([ZSegmentAttributeFilter, ZSegmentPersonFilter, ZSegmentSegmentFilter, ZSegmentDeviceFilter])
|
||||
// we need to refine the filter to make sure that the filter is valid
|
||||
.refine(
|
||||
(filter) => {
|
||||
if (filter.root.type === "action") {
|
||||
if (!("metric" in filter.qualifier)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Metric operator must be specified for action filters",
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(filter) => {
|
||||
// if the operator is an arithmentic operator, the value must be a number
|
||||
@@ -371,6 +295,5 @@ export interface TEvaluateSegmentUserData {
|
||||
userId: string;
|
||||
environmentId: string;
|
||||
attributes: TEvaluateSegmentUserAttributeData;
|
||||
actionIds: string[];
|
||||
deviceType: "phone" | "desktop";
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export const ZSurveyEndScreenCard = ZSurveyEndingBase.extend({
|
||||
headline: ZI18nString.optional(),
|
||||
subheader: ZI18nString.optional(),
|
||||
buttonLabel: ZI18nString.optional(),
|
||||
buttonLink: z.string().optional(),
|
||||
buttonLink: z.string().url("Invalid Button Url in Ending card").optional(),
|
||||
imageUrl: z.string().optional(),
|
||||
videoUrl: z.string().optional(),
|
||||
});
|
||||
@@ -38,7 +38,7 @@ export type TSurveyEndScreenCard = z.infer<typeof ZSurveyEndScreenCard>;
|
||||
|
||||
export const ZSurveyRedirectUrlCard = ZSurveyEndingBase.extend({
|
||||
type: z.literal("redirectToUrl"),
|
||||
url: z.string().url("Invalid redirect Url in Ending card").optional(),
|
||||
url: z.string().url("Invalid Redirect Url in Ending card").optional(),
|
||||
label: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -67,6 +67,7 @@ export enum TSurveyQuestionTypeEnum {
|
||||
Matrix = "matrix",
|
||||
Address = "address",
|
||||
Ranking = "ranking",
|
||||
ContactInfo = "contactInfo",
|
||||
}
|
||||
|
||||
export const ZSurveyQuestionId = z.string().superRefine((id, ctx) => {
|
||||
@@ -577,17 +578,33 @@ export const ZSurveyMatrixQuestion = ZSurveyQuestionBase.extend({
|
||||
|
||||
export type TSurveyMatrixQuestion = z.infer<typeof ZSurveyMatrixQuestion>;
|
||||
|
||||
const ZSurveyShowRequiredToggle = z.object({
|
||||
show: z.boolean(),
|
||||
required: z.boolean(),
|
||||
});
|
||||
|
||||
export const ZSurveyAddressQuestion = ZSurveyQuestionBase.extend({
|
||||
type: z.literal(TSurveyQuestionTypeEnum.Address),
|
||||
isAddressLine1Required: z.boolean().default(false),
|
||||
isAddressLine2Required: z.boolean().default(false),
|
||||
isCityRequired: z.boolean().default(false),
|
||||
isStateRequired: z.boolean().default(false),
|
||||
isZipRequired: z.boolean().default(false),
|
||||
isCountryRequired: z.boolean().default(false),
|
||||
addressLine1: ZSurveyShowRequiredToggle,
|
||||
addressLine2: ZSurveyShowRequiredToggle,
|
||||
city: ZSurveyShowRequiredToggle,
|
||||
state: ZSurveyShowRequiredToggle,
|
||||
zip: ZSurveyShowRequiredToggle,
|
||||
country: ZSurveyShowRequiredToggle,
|
||||
});
|
||||
export type TSurveyAddressQuestion = z.infer<typeof ZSurveyAddressQuestion>;
|
||||
|
||||
export const ZSurveyContactInfoQuestion = ZSurveyQuestionBase.extend({
|
||||
type: z.literal(TSurveyQuestionTypeEnum.ContactInfo),
|
||||
firstName: ZSurveyShowRequiredToggle,
|
||||
lastName: ZSurveyShowRequiredToggle,
|
||||
email: ZSurveyShowRequiredToggle,
|
||||
phone: ZSurveyShowRequiredToggle,
|
||||
company: ZSurveyShowRequiredToggle,
|
||||
});
|
||||
|
||||
export type TSurveyContactInfoQuestion = z.infer<typeof ZSurveyContactInfoQuestion>;
|
||||
|
||||
export const ZSurveyRankingQuestion = ZSurveyQuestionBase.extend({
|
||||
type: z.literal(TSurveyQuestionTypeEnum.Ranking),
|
||||
choices: z
|
||||
@@ -613,6 +630,7 @@ export const ZSurveyQuestion = z.union([
|
||||
ZSurveyMatrixQuestion,
|
||||
ZSurveyAddressQuestion,
|
||||
ZSurveyRankingQuestion,
|
||||
ZSurveyContactInfoQuestion,
|
||||
]);
|
||||
|
||||
export type TSurveyQuestion = z.infer<typeof ZSurveyQuestion>;
|
||||
@@ -636,6 +654,7 @@ export const ZSurveyQuestionType = z.enum([
|
||||
TSurveyQuestionTypeEnum.Rating,
|
||||
TSurveyQuestionTypeEnum.Cal,
|
||||
TSurveyQuestionTypeEnum.Ranking,
|
||||
TSurveyQuestionTypeEnum.ContactInfo,
|
||||
]);
|
||||
|
||||
export type TSurveyQuestionType = z.infer<typeof ZSurveyQuestionType>;
|
||||
@@ -1031,6 +1050,32 @@ export const ZSurvey = z
|
||||
}
|
||||
}
|
||||
|
||||
if (question.type === TSurveyQuestionTypeEnum.ContactInfo) {
|
||||
const { company, email, firstName, lastName, phone } = question;
|
||||
const fields = [company, email, firstName, lastName, phone];
|
||||
|
||||
if (fields.every((field) => !field.show)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "At least one field must be shown in the Contact Info question",
|
||||
path: ["questions", questionIndex],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (question.type === TSurveyQuestionTypeEnum.Address) {
|
||||
const { addressLine1, addressLine2, city, state, zip, country } = question;
|
||||
const fields = [addressLine1, addressLine2, city, state, zip, country];
|
||||
|
||||
if (fields.every((field) => !field.show)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "At least one field must be shown in the Address question",
|
||||
path: ["questions", questionIndex],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (question.logic) {
|
||||
const logicIssues = validateLogic(survey, questionIndex, question.logic);
|
||||
|
||||
@@ -2291,11 +2336,32 @@ export const ZSurveyQuestionSummaryAddress = z.object({
|
||||
|
||||
export type TSurveyQuestionSummaryAddress = z.infer<typeof ZSurveyQuestionSummaryAddress>;
|
||||
|
||||
export const ZSurveyQuestionSummaryContactInfo = z.object({
|
||||
type: z.literal("contactInfo"),
|
||||
question: ZSurveyContactInfoQuestion,
|
||||
responseCount: z.number(),
|
||||
samples: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
updatedAt: z.date(),
|
||||
value: z.array(z.string()),
|
||||
person: z
|
||||
.object({
|
||||
id: ZId,
|
||||
userId: z.string(),
|
||||
})
|
||||
.nullable(),
|
||||
personAttributes: ZAttributes.nullable(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export type TSurveyQuestionSummaryContactInfo = z.infer<typeof ZSurveyQuestionSummaryContactInfo>;
|
||||
|
||||
export const ZSurveyQuestionSummaryRanking = z.object({
|
||||
type: z.literal("ranking"),
|
||||
question: ZSurveyRankingQuestion,
|
||||
responseCount: z.number(),
|
||||
|
||||
choices: z.array(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
@@ -2334,6 +2400,7 @@ export const ZSurveyQuestionSummary = z.union([
|
||||
ZSurveyQuestionSummaryMatrix,
|
||||
ZSurveyQuestionSummaryAddress,
|
||||
ZSurveyQuestionSummaryRanking,
|
||||
ZSurveyQuestionSummaryContactInfo,
|
||||
]);
|
||||
|
||||
export type TSurveyQuestionSummary = z.infer<typeof ZSurveyQuestionSummary>;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
interface AddressResponseProps {
|
||||
interface ArrayResponseProps {
|
||||
value: string[];
|
||||
}
|
||||
|
||||
export const AddressResponse = ({ value }: AddressResponseProps) => {
|
||||
export const ArrayResponse = ({ value }: ArrayResponseProps) => {
|
||||
return (
|
||||
<div className="my-1 font-normal text-slate-700" dir="auto">
|
||||
{value.map(
|
||||
@@ -32,10 +32,11 @@
|
||||
position: relative;
|
||||
border-bottom-left-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
overflow: scroll;
|
||||
overflow: auto;
|
||||
resize: vertical;
|
||||
height: auto;
|
||||
min-height: 100px;
|
||||
max-height: 150px;
|
||||
}
|
||||
|
||||
.editor-input {
|
||||
|
||||
@@ -234,7 +234,7 @@ export const PreviewSurvey = ({
|
||||
: "expanded_with_fixed_positioning"
|
||||
: "shrink"
|
||||
}
|
||||
className="relative flex items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
|
||||
className="relative flex h-full w-[95%] items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
|
||||
{previewMode === "mobile" && (
|
||||
<>
|
||||
<p className="absolute left-0 top-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
|
||||
|
||||
75
packages/ui/components/QuestionToggleTable/index.tsx
Normal file
75
packages/ui/components/QuestionToggleTable/index.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
||||
import { Switch } from "../Switch";
|
||||
|
||||
interface QuestionToggleTableProps {
|
||||
type: "address" | "contact";
|
||||
fields: {
|
||||
required: boolean;
|
||||
show: boolean;
|
||||
id: string;
|
||||
label: string;
|
||||
}[];
|
||||
onShowToggle: (
|
||||
field: {
|
||||
id: string;
|
||||
required: boolean;
|
||||
show: boolean;
|
||||
},
|
||||
show: boolean
|
||||
) => void;
|
||||
onRequiredToggle: (
|
||||
field: {
|
||||
id: string;
|
||||
show: boolean;
|
||||
required: boolean;
|
||||
},
|
||||
required: boolean
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const QuestionToggleTable = ({
|
||||
type,
|
||||
fields,
|
||||
onShowToggle,
|
||||
onRequiredToggle,
|
||||
}: QuestionToggleTableProps) => {
|
||||
return (
|
||||
<table className="mt-4 w-1/2 table-fixed">
|
||||
<thead>
|
||||
<tr className="text-left text-slate-800">
|
||||
<th className="w-1/2 text-sm font-semibold">{capitalizeFirstLetter(type)} Fields</th>
|
||||
<th className="w-1/4 text-sm font-semibold">Show</th>
|
||||
<th className="w-1/4 text-sm font-semibold">Required</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.map((field) => (
|
||||
<tr className="text-slate-900">
|
||||
<td className="py-2 text-sm">{field.label}</td>
|
||||
<td className="py-">
|
||||
<Switch
|
||||
checked={field.show}
|
||||
onCheckedChange={(show) => {
|
||||
onShowToggle(field, show);
|
||||
}}
|
||||
disabled={
|
||||
// if all the other fields are hidden, this should be disabled
|
||||
fields.filter((currentField) => currentField.id !== field.id).every((field) => !field.show)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<Switch
|
||||
checked={field.required}
|
||||
onCheckedChange={(required) => {
|
||||
onRequiredToggle(field, required);
|
||||
}}
|
||||
disabled={!field.show}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyRatingQuestion,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { AddressResponse } from "../../AddressResponse";
|
||||
import { ArrayResponse } from "../../ArrayResponse";
|
||||
import { FileUploadResponse } from "../../FileUploadResponse";
|
||||
import { PictureSelectionResponse } from "../../PictureSelectionResponse";
|
||||
import { RankingRespone } from "../../RankingResponse";
|
||||
@@ -109,10 +109,12 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
}
|
||||
break;
|
||||
case TSurveyQuestionTypeEnum.Address:
|
||||
case TSurveyQuestionTypeEnum.ContactInfo:
|
||||
if (Array.isArray(responseData)) {
|
||||
return <AddressResponse value={responseData} />;
|
||||
return <ArrayResponse value={responseData} />;
|
||||
}
|
||||
break;
|
||||
|
||||
case TSurveyQuestionTypeEnum.Cal:
|
||||
if (typeof responseData === "string" || typeof responseData === "number") {
|
||||
return (
|
||||
|
||||
@@ -61,7 +61,7 @@ export const TemplateList = ({
|
||||
const filteredTemplates = useMemo(() => {
|
||||
return templates.filter((template) => {
|
||||
if (templateSearch) {
|
||||
return template.name.toLowerCase().startsWith(templateSearch.toLowerCase());
|
||||
return template.name.toLowerCase().includes(templateSearch.toLowerCase());
|
||||
}
|
||||
// Parse and validate the filters
|
||||
const channelParseResult = ZSurveyType.nullable().safeParse(selectedFilter[0]);
|
||||
|
||||
Reference in New Issue
Block a user