mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
chore: fix eslint issues in ee & email packages (#2742)
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { LocalizedEditor } from "@formbricks/ee/multiLanguage/components/LocalizedEditor";
|
||||
import { LocalizedEditor } from "@formbricks/ee/multi-language/components/localized-editor";
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import { TSurvey, TSurveyCTAQuestion } from "@formbricks/types/surveys";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { LocalizedEditor } from "@formbricks/ee/multiLanguage/components/LocalizedEditor";
|
||||
import { LocalizedEditor } from "@formbricks/ee/multi-language/components/localized-editor";
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import { TSurvey, TSurveyConsentQuestion } from "@formbricks/types/surveys";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { LocalizedEditor } from "@formbricks/ee/multiLanguage/components/LocalizedEditor";
|
||||
import { LocalizedEditor } from "@formbricks/ee/multi-language/components/localized-editor";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
|
||||
@@ -18,7 +17,7 @@ import {
|
||||
TSurveyLogic,
|
||||
TSurveyLogicCondition,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionType,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import {
|
||||
@@ -66,7 +65,7 @@ export const LogicEditor = ({
|
||||
return question.choices.map((choice) => getLocalizedValue(choice.label, "default"));
|
||||
} else if ("range" in question) {
|
||||
return Array.from({ length: question.range ? question.range : 0 }, (_, i) => (i + 1).toString());
|
||||
} else if (question.type === TSurveyQuestionType.NPS) {
|
||||
} else if (question.type === TSurveyQuestionTypeEnum.NPS) {
|
||||
return Array.from({ length: 11 }, (_, i) => (i + 0).toString());
|
||||
}
|
||||
return [];
|
||||
|
||||
@@ -5,7 +5,6 @@ import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
@@ -14,13 +13,12 @@ import {
|
||||
TShuffleOption,
|
||||
TSurvey,
|
||||
TSurveyMultipleChoiceQuestion,
|
||||
TSurveyQuestionType,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
|
||||
|
||||
import { SelectQuestionChoice } from "./SelectQuestionChoice";
|
||||
|
||||
interface OpenQuestionFormProps {
|
||||
@@ -309,13 +307,13 @@ export const MultipleChoiceQuestionForm = ({
|
||||
onClick={() => {
|
||||
updateQuestion(questionIdx, {
|
||||
type:
|
||||
question.type === TSurveyQuestionType.MultipleChoiceMulti
|
||||
? TSurveyQuestionType.MultipleChoiceSingle
|
||||
: TSurveyQuestionType.MultipleChoiceMulti,
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
|
||||
? TSurveyQuestionTypeEnum.MultipleChoiceSingle
|
||||
: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
});
|
||||
}}>
|
||||
Convert to {question.type === TSurveyQuestionType.MultipleChoiceSingle ? "Multiple" : "Single"}{" "}
|
||||
Select
|
||||
Convert to{" "}
|
||||
{question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ? "Multiple" : "Single"} Select
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-1 items-center justify-end gap-2">
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { QUESTIONS_ICON_MAP, getTSurveyQuestionTypeName } from "@/app/lib/questions";
|
||||
import { QUESTIONS_ICON_MAP, getTSurveyQuestionTypeEnumName } from "@/app/lib/questions";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TI18nString, TSurvey, TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { TI18nString, TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
|
||||
import { Switch } from "@formbricks/ui/Switch";
|
||||
|
||||
import { AddressQuestionForm } from "./AddressQuestionForm";
|
||||
import { AdvancedSettings } from "./AdvancedSettings";
|
||||
import { CTAQuestionForm } from "./CTAQuestionForm";
|
||||
@@ -192,7 +190,7 @@ export const QuestionCard = ({
|
||||
attributeClasses
|
||||
)[selectedLanguageCode] ?? ""
|
||||
)
|
||||
: getTSurveyQuestionTypeName(question.type)}
|
||||
: getTSurveyQuestionTypeEnumName(question.type)}
|
||||
</p>
|
||||
{!open && question?.required && (
|
||||
<p className="mt-1 truncate text-xs text-slate-500">{question?.required && "Required"}</p>
|
||||
@@ -216,7 +214,7 @@ export const QuestionCard = ({
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="px-4 pb-4">
|
||||
{question.type === TSurveyQuestionType.OpenText ? (
|
||||
{question.type === TSurveyQuestionTypeEnum.OpenText ? (
|
||||
<OpenQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
@@ -228,7 +226,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.MultipleChoiceSingle ? (
|
||||
) : question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ? (
|
||||
<MultipleChoiceQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
@@ -240,7 +238,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.MultipleChoiceMulti ? (
|
||||
) : question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ? (
|
||||
<MultipleChoiceQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
@@ -252,7 +250,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.NPS ? (
|
||||
) : question.type === TSurveyQuestionTypeEnum.NPS ? (
|
||||
<NPSQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
@@ -264,7 +262,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.CTA ? (
|
||||
) : question.type === TSurveyQuestionTypeEnum.CTA ? (
|
||||
<CTAQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
@@ -276,7 +274,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Rating ? (
|
||||
) : question.type === TSurveyQuestionTypeEnum.Rating ? (
|
||||
<RatingQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
@@ -288,7 +286,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Consent ? (
|
||||
) : question.type === TSurveyQuestionTypeEnum.Consent ? (
|
||||
<ConsentQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
@@ -299,7 +297,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Date ? (
|
||||
) : question.type === TSurveyQuestionTypeEnum.Date ? (
|
||||
<DateQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
@@ -311,7 +309,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.PictureSelection ? (
|
||||
) : question.type === TSurveyQuestionTypeEnum.PictureSelection ? (
|
||||
<PictureSelectionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
@@ -323,7 +321,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.FileUpload ? (
|
||||
) : question.type === TSurveyQuestionTypeEnum.FileUpload ? (
|
||||
<FileUploadQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
product={product}
|
||||
@@ -336,7 +334,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Cal ? (
|
||||
) : question.type === TSurveyQuestionTypeEnum.Cal ? (
|
||||
<CalQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
@@ -348,7 +346,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Matrix ? (
|
||||
) : question.type === TSurveyQuestionTypeEnum.Matrix ? (
|
||||
<MatrixQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
@@ -360,7 +358,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Address ? (
|
||||
) : question.type === TSurveyQuestionTypeEnum.Address ? (
|
||||
<AddressQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
@@ -385,9 +383,9 @@ export const QuestionCard = ({
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
|
||||
<Collapsible.CollapsibleContent className="space-y-4">
|
||||
{question.type !== TSurveyQuestionType.NPS &&
|
||||
question.type !== TSurveyQuestionType.Rating &&
|
||||
question.type !== TSurveyQuestionType.CTA ? (
|
||||
{question.type !== TSurveyQuestionTypeEnum.NPS &&
|
||||
question.type !== TSurveyQuestionTypeEnum.Rating &&
|
||||
question.type !== TSurveyQuestionTypeEnum.CTA ? (
|
||||
<div className="mt-2 flex space-x-2">
|
||||
<div className="w-full">
|
||||
<QuestionFormInput
|
||||
@@ -433,8 +431,8 @@ export const QuestionCard = ({
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{(question.type === TSurveyQuestionType.Rating ||
|
||||
question.type === TSurveyQuestionType.NPS) &&
|
||||
{(question.type === TSurveyQuestionTypeEnum.Rating ||
|
||||
question.type === TSurveyQuestionTypeEnum.NPS) &&
|
||||
questionIdx !== 0 && (
|
||||
<div className="mt-4">
|
||||
<QuestionFormInput
|
||||
|
||||
@@ -4,9 +4,8 @@ import { QUESTIONS_ICON_MAP, QUESTIONS_NAME_MAP, getQuestionDefaults } from "@/a
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys";
|
||||
import { ConfirmationModal } from "@formbricks/ui/ConfirmationModal";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -44,7 +43,7 @@ export const QuestionMenu = ({
|
||||
const [logicWarningModal, setLogicWarningModal] = useState(false);
|
||||
const [changeToType, setChangeToType] = useState(question.type);
|
||||
|
||||
const changeQuestionType = (type: TSurveyQuestionType) => {
|
||||
const changeQuestionType = (type: TSurveyQuestionTypeEnum) => {
|
||||
const { headline, required, subheader, imageUrl, videoUrl, buttonLabel, backButtonLabel } = question;
|
||||
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
@@ -52,10 +51,10 @@ export const QuestionMenu = ({
|
||||
// if going from single select to multi select or vice versa, we need to keep the choices as well
|
||||
|
||||
if (
|
||||
(type === TSurveyQuestionType.MultipleChoiceSingle &&
|
||||
question.type === TSurveyQuestionType.MultipleChoiceMulti) ||
|
||||
(type === TSurveyQuestionType.MultipleChoiceMulti &&
|
||||
question.type === TSurveyQuestionType.MultipleChoiceSingle)
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceSingle &&
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) ||
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle)
|
||||
) {
|
||||
updateQuestion(questionIdx, {
|
||||
choices: question.choices,
|
||||
@@ -80,7 +79,7 @@ export const QuestionMenu = ({
|
||||
});
|
||||
};
|
||||
|
||||
const addQuestionBelow = (type: TSurveyQuestionType) => {
|
||||
const addQuestionBelow = (type: TSurveyQuestionTypeEnum) => {
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
|
||||
addQuestion(
|
||||
@@ -143,15 +142,15 @@ export const QuestionMenu = ({
|
||||
key={type}
|
||||
className="min-h-8 cursor-pointer text-slate-500"
|
||||
onClick={() => {
|
||||
setChangeToType(type as TSurveyQuestionType);
|
||||
setChangeToType(type as TSurveyQuestionTypeEnum);
|
||||
if (question.logic) {
|
||||
setLogicWarningModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
changeQuestionType(type as TSurveyQuestionType);
|
||||
changeQuestionType(type as TSurveyQuestionTypeEnum);
|
||||
}}>
|
||||
{QUESTIONS_ICON_MAP[type as TSurveyQuestionType]}
|
||||
{QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}
|
||||
<span className="ml-2">{name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
@@ -176,9 +175,9 @@ export const QuestionMenu = ({
|
||||
className="min-h-8 cursor-pointer text-slate-500"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
addQuestionBelow(type as TSurveyQuestionType);
|
||||
addQuestionBelow(type as TSurveyQuestionTypeEnum);
|
||||
}}>
|
||||
{QUESTIONS_ICON_MAP[type as TSurveyQuestionType]}
|
||||
{QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}
|
||||
<span className="ml-2">{name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ import { createId } from "@paralleldrive/cuid2";
|
||||
import React, { SetStateAction, useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { MultiLanguageCard } from "@formbricks/ee/multiLanguage/components/MultiLanguageCard";
|
||||
import { MultiLanguageCard } from "@formbricks/ee/multi-language/components/multi-language-card";
|
||||
import { extractLanguageCodes, getLocalizedValue, translateQuestion } from "@formbricks/lib/i18n/utils";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { checkForEmptyFallBackValue, extractRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AdvancedTargetingCard } from "@formbricks/ee/advancedTargeting/components/AdvancedTargetingCard";
|
||||
import { AdvancedTargetingCard } from "@formbricks/ee/advanced-targeting/components/advanced-targeting-card";
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { createSegmentAction } from "@formbricks/ee/advancedTargeting/lib/actions";
|
||||
import { createSegmentAction } from "@formbricks/ee/advanced-targeting/lib/actions";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// extend this object in order to add more validation rules
|
||||
import { isEqual } from "lodash";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
import { extractLanguageCodes, getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { checkForEmptyFallBackValue } from "@formbricks/lib/utils/recall";
|
||||
import { ZSegmentFilters } from "@formbricks/types/segment";
|
||||
@@ -16,7 +15,7 @@ import {
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyPictureSelectionQuestion,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionType,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyQuestions,
|
||||
TSurveyThankYouCard,
|
||||
TSurveyWelcomeCard,
|
||||
@@ -363,8 +362,8 @@ export const isSurveyValid = (
|
||||
existingQuestionIds.add(question.id);
|
||||
|
||||
if (
|
||||
question.type === TSurveyQuestionType.MultipleChoiceSingle ||
|
||||
question.type === TSurveyQuestionType.MultipleChoiceMulti
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
|
||||
) {
|
||||
const haveSameChoices =
|
||||
question.choices.some((element) => element.label[selectedLanguageCode]?.trim() === "") ||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { createSegmentAction } from "@formbricks/ee/advancedTargeting/lib/actions";
|
||||
import { createSegmentAction } from "@formbricks/ee/advanced-targeting/lib/actions";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import { TBaseFilter, TSegment, ZSegmentFilters } from "@formbricks/types/segment";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { UsersIcon } from "lucide-react";
|
||||
|
||||
import { SegmentSettings } from "@formbricks/ee/advancedTargeting/components/SegmentSettings";
|
||||
import { SegmentSettings } from "@formbricks/ee/advanced-targeting/components/segment-settings";
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
|
||||
@@ -2,8 +2,8 @@ import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environment
|
||||
import { BasicCreateSegmentModal } from "@/app/(app)/environments/[environmentId]/(people)/segments/components/BasicCreateSegmentModal";
|
||||
import { SegmentTable } from "@/app/(app)/environments/[environmentId]/(people)/segments/components/SegmentTable";
|
||||
|
||||
import { CreateSegmentModal } from "@formbricks/ee/advancedTargeting/components/CreateSegmentModal";
|
||||
import { ACTIONS_TO_EXCLUDE } from "@formbricks/ee/advancedTargeting/lib/constants";
|
||||
import { CreateSegmentModal } from "@formbricks/ee/advanced-targeting/components/create-segment-modal";
|
||||
import { ACTIONS_TO_EXCLUDE } from "@formbricks/ee/advanced-targeting/lib/constants";
|
||||
import { getAdvancedTargetingPermission } from "@formbricks/ee/lib/service";
|
||||
import { getActionClasses } from "@formbricks/lib/actionClass/service";
|
||||
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
|
||||
|
||||
@@ -11,7 +11,6 @@ import Image from "next/image";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
|
||||
@@ -22,7 +21,7 @@ import {
|
||||
TIntegrationNotionConfigData,
|
||||
TIntegrationNotionDatabase,
|
||||
} from "@formbricks/types/integration/notion";
|
||||
import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { DropdownSelector } from "@formbricks/ui/DropdownSelector";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
@@ -123,7 +122,7 @@ export const AddIntegrationModal = ({
|
||||
? selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
|
||||
id: fId,
|
||||
name: fId,
|
||||
type: TSurveyQuestionType.OpenText,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
})) || []
|
||||
: [];
|
||||
return [...questions, ...hiddenFields];
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys";
|
||||
|
||||
export const TYPE_MAPPING = {
|
||||
[TSurveyQuestionType.CTA]: ["checkbox"],
|
||||
[TSurveyQuestionType.MultipleChoiceMulti]: ["multi_select"],
|
||||
[TSurveyQuestionType.MultipleChoiceSingle]: ["select", "status"],
|
||||
[TSurveyQuestionType.OpenText]: [
|
||||
[TSurveyQuestionTypeEnum.CTA]: ["checkbox"],
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: ["multi_select"],
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: ["select", "status"],
|
||||
[TSurveyQuestionTypeEnum.OpenText]: [
|
||||
"created_by",
|
||||
"created_time",
|
||||
"date",
|
||||
@@ -17,15 +17,15 @@ export const TYPE_MAPPING = {
|
||||
"title",
|
||||
"url",
|
||||
],
|
||||
[TSurveyQuestionType.NPS]: ["number"],
|
||||
[TSurveyQuestionType.Consent]: ["checkbox"],
|
||||
[TSurveyQuestionType.Rating]: ["number"],
|
||||
[TSurveyQuestionType.PictureSelection]: ["url"],
|
||||
[TSurveyQuestionType.FileUpload]: ["url"],
|
||||
[TSurveyQuestionType.Date]: ["date"],
|
||||
[TSurveyQuestionType.Address]: ["rich_text"],
|
||||
[TSurveyQuestionType.Matrix]: ["rich_text"],
|
||||
[TSurveyQuestionType.Cal]: ["checkbox"],
|
||||
[TSurveyQuestionTypeEnum.NPS]: ["number"],
|
||||
[TSurveyQuestionTypeEnum.Consent]: ["checkbox"],
|
||||
[TSurveyQuestionTypeEnum.Rating]: ["number"],
|
||||
[TSurveyQuestionTypeEnum.PictureSelection]: ["url"],
|
||||
[TSurveyQuestionTypeEnum.FileUpload]: ["url"],
|
||||
[TSurveyQuestionTypeEnum.Date]: ["date"],
|
||||
[TSurveyQuestionTypeEnum.Address]: ["rich_text"],
|
||||
[TSurveyQuestionTypeEnum.Matrix]: ["rich_text"],
|
||||
[TSurveyQuestionTypeEnum.Cal]: ["checkbox"],
|
||||
};
|
||||
|
||||
export const UNSUPPORTED_TYPES_BY_NOTION = [
|
||||
|
||||
@@ -3,7 +3,7 @@ import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { getMultiLanguagePermission } from "@formbricks/ee/lib/service";
|
||||
import { EditLanguage } from "@formbricks/ee/multiLanguage/components/EditLanguage";
|
||||
import { EditLanguage } from "@formbricks/ee/multi-language/components/edit-language";
|
||||
import { getOrganization } from "@formbricks/lib/organization/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { StripePriceLookupKeys } from "@formbricks/ee/billing/lib/constants";
|
||||
import { createCustomerPortalSession } from "@formbricks/ee/billing/lib/createCustomerPortalSession";
|
||||
import { createSubscription } from "@formbricks/ee/billing/lib/createSubscription";
|
||||
import { removeSubscription } from "@formbricks/ee/billing/lib/removeSubscription";
|
||||
import { createCustomerPortalSession } from "@formbricks/ee/billing/lib/create-customer-portal-session";
|
||||
import { createSubscription } from "@formbricks/ee/billing/lib/create-subscription";
|
||||
import { removeSubscription } from "@formbricks/ee/billing/lib/remove-subscription";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { canUserAccessOrganization } from "@formbricks/lib/organization/auth";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MemberActions } from "@/app/(app)/environments/[environmentId]/settings/(organization)/members/components/EditMemberships/MemberActions";
|
||||
import { isInviteExpired } from "@/app/lib/utils";
|
||||
|
||||
import { EditMembershipRole } from "@formbricks/ee/RoleManagement/components/EditMembershipRole";
|
||||
import { EditMembershipRole } from "@formbricks/ee/role-management/components/edit-membership-role";
|
||||
import { TInvite } from "@formbricks/types/invites";
|
||||
import { TMember, TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { AddMemberRole } from "@formbricks/ee/RoleManagement/components/AddMemberRole";
|
||||
import { AddMemberRole } from "@formbricks/ee/role-management/components/add-member-role";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
@@ -11,15 +11,13 @@ import { NPSSummary } from "@/app/(app)/environments/[environmentId]/surveys/[su
|
||||
import { OpenTextSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary";
|
||||
import { PictureChoiceSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary";
|
||||
import { RatingSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary";
|
||||
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurveySummary } from "@formbricks/types/surveys";
|
||||
import { TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { EmptySpaceFiller } from "@formbricks/ui/EmptySpaceFiller";
|
||||
import { SkeletonLoader } from "@formbricks/ui/SkeletonLoader";
|
||||
|
||||
import { AddressSummary } from "./AddressSummary";
|
||||
|
||||
interface SummaryListProps {
|
||||
@@ -58,7 +56,7 @@ export const SummaryList = ({
|
||||
/>
|
||||
) : (
|
||||
summary.map((questionSummary) => {
|
||||
if (questionSummary.type === TSurveyQuestionType.OpenText) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.OpenText) {
|
||||
return (
|
||||
<OpenTextSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -70,8 +68,8 @@ export const SummaryList = ({
|
||||
);
|
||||
}
|
||||
if (
|
||||
questionSummary.type === TSurveyQuestionType.MultipleChoiceSingle ||
|
||||
questionSummary.type === TSurveyQuestionType.MultipleChoiceMulti
|
||||
questionSummary.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
|
||||
questionSummary.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
|
||||
) {
|
||||
return (
|
||||
<MultipleChoiceSummary
|
||||
@@ -84,7 +82,7 @@ export const SummaryList = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionType.NPS) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.NPS) {
|
||||
return (
|
||||
<NPSSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -94,7 +92,7 @@ export const SummaryList = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionType.CTA) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.CTA) {
|
||||
return (
|
||||
<CTASummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -104,7 +102,7 @@ export const SummaryList = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionType.Rating) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Rating) {
|
||||
return (
|
||||
<RatingSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -114,7 +112,7 @@ export const SummaryList = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionType.Consent) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Consent) {
|
||||
return (
|
||||
<ConsentSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -124,7 +122,7 @@ export const SummaryList = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionType.PictureSelection) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.PictureSelection) {
|
||||
return (
|
||||
<PictureChoiceSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -134,7 +132,7 @@ export const SummaryList = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionType.Date) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Date) {
|
||||
return (
|
||||
<DateQuestionSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -145,7 +143,7 @@ export const SummaryList = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionType.FileUpload) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.FileUpload) {
|
||||
return (
|
||||
<FileUploadSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -156,7 +154,7 @@ export const SummaryList = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionType.Cal) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Cal) {
|
||||
return (
|
||||
<CalSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -167,7 +165,7 @@ export const SummaryList = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionType.Matrix) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||
return (
|
||||
<MatrixQuestionSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -177,7 +175,7 @@ export const SummaryList = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionType.Address) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Address) {
|
||||
return (
|
||||
<AddressSummary
|
||||
key={questionSummary.question.id}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getPreviewEmailTemplateHtml } from "@formbricks/email/components/survey/PreviewEmailTemplate";
|
||||
import { getPreviewEmailTemplateHtml } from "@formbricks/email/components/survey/preview-email-template";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
|
||||
@@ -4,10 +4,9 @@ import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[s
|
||||
import clsx from "clsx";
|
||||
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { useClickOutside } from "@formbricks/lib/utils/hooks/useClickOutside";
|
||||
import { TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from "@formbricks/ui/Command";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -23,7 +22,7 @@ type QuestionFilterComboBoxProps = {
|
||||
filterComboBoxValue: string | string[] | undefined;
|
||||
onChangeFilterValue: (o: string) => void;
|
||||
onChangeFilterComboBoxValue: (o: string | string[]) => void;
|
||||
type?: TSurveyQuestionType | Omit<OptionsType, OptionsType.QUESTIONS>;
|
||||
type?: TSurveyQuestionTypeEnum | Omit<OptionsType, OptionsType.QUESTIONS>;
|
||||
handleRemoveMultiSelect: (value: string[]) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
@@ -47,9 +46,9 @@ export const QuestionFilterComboBox = ({
|
||||
|
||||
// multiple when question type is multi selection
|
||||
const isMultiple =
|
||||
type === TSurveyQuestionType.MultipleChoiceMulti ||
|
||||
type === TSurveyQuestionType.MultipleChoiceSingle ||
|
||||
type === TSurveyQuestionType.PictureSelection;
|
||||
type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
|
||||
type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
|
||||
type === TSurveyQuestionTypeEnum.PictureSelection;
|
||||
|
||||
// when question type is multi selection so we remove the option from the options which has been already selected
|
||||
const options = isMultiple
|
||||
@@ -63,7 +62,7 @@ export const QuestionFilterComboBox = ({
|
||||
|
||||
// disable the combo box for selection of value when question type is nps or rating and selected value is submitted or skipped
|
||||
const isDisabledComboBox =
|
||||
(type === TSurveyQuestionType.NPS || type === TSurveyQuestionType.Rating) &&
|
||||
(type === TSurveyQuestionTypeEnum.NPS || type === TSurveyQuestionTypeEnum.Rating) &&
|
||||
(filterValue === "Submitted" || filterValue === "Skipped");
|
||||
|
||||
return (
|
||||
|
||||
@@ -22,10 +22,9 @@ import {
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { useClickOutside } from "@formbricks/lib/utils/hooks/useClickOutside";
|
||||
import { TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -47,7 +46,7 @@ export enum OptionsType {
|
||||
|
||||
export type QuestionOption = {
|
||||
label: string;
|
||||
questionType?: TSurveyQuestionType;
|
||||
questionType?: TSurveyQuestionTypeEnum;
|
||||
type: OptionsType;
|
||||
id: string;
|
||||
};
|
||||
@@ -67,25 +66,25 @@ const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOpti
|
||||
switch (type) {
|
||||
case OptionsType.QUESTIONS:
|
||||
switch (questionType) {
|
||||
case TSurveyQuestionType.OpenText:
|
||||
case TSurveyQuestionTypeEnum.OpenText:
|
||||
return <MessageSquareTextIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionType.Rating:
|
||||
case TSurveyQuestionTypeEnum.Rating:
|
||||
return <StarIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionType.CTA:
|
||||
case TSurveyQuestionTypeEnum.CTA:
|
||||
return <MousePointerClickIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionType.OpenText:
|
||||
case TSurveyQuestionTypeEnum.OpenText:
|
||||
return <HelpCircleIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionType.MultipleChoiceMulti:
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
|
||||
return <ListIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionType.MultipleChoiceSingle:
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
return <Rows3Icon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionType.NPS:
|
||||
case TSurveyQuestionTypeEnum.NPS:
|
||||
return <NetPromoterScoreIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionType.Consent:
|
||||
case TSurveyQuestionTypeEnum.Consent:
|
||||
return <CheckIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionType.PictureSelection:
|
||||
case TSurveyQuestionTypeEnum.PictureSelection:
|
||||
return <ImageIcon width={18} className="text-white" />;
|
||||
case TSurveyQuestionType.Matrix:
|
||||
case TSurveyQuestionTypeEnum.Matrix:
|
||||
return <GridIcon width={18} className="text-white" />;
|
||||
}
|
||||
case OptionsType.ATTRIBUTES:
|
||||
|
||||
@@ -14,16 +14,14 @@ import { TrashIcon } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp, Plus } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Checkbox } from "@formbricks/ui/Checkbox";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@formbricks/ui/Popover";
|
||||
|
||||
import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsComboBox";
|
||||
|
||||
export type QuestionFilterOptions = {
|
||||
type: TSurveyQuestionType | "Attributes" | "Tags" | "Languages";
|
||||
type: TSurveyQuestionTypeEnum | "Attributes" | "Tags" | "Languages";
|
||||
filterOptions: string[];
|
||||
filterComboBoxOptions: string[];
|
||||
id: string;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { responses } from "@/app/lib/api/response";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { ProductFeatureKeys } from "@formbricks/ee/billing/lib/constants";
|
||||
import { reportUsageToStripe } from "@formbricks/ee/billing/lib/reportUsage";
|
||||
import { reportUsageToStripe } from "@formbricks/ee/billing/lib/report-usage";
|
||||
import { CRON_SECRET, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { TIntegrationGoogleSheets } from "@formbricks/types/integration/googleSh
|
||||
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
||||
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
||||
import { TPipelineInput } from "@formbricks/types/pipelines";
|
||||
import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys";
|
||||
|
||||
export const handleIntegrations = async (
|
||||
integrations: TIntegration[],
|
||||
@@ -103,7 +103,7 @@ const extractResponses = async (
|
||||
|
||||
if (responseValue !== undefined) {
|
||||
let answer: typeof responseValue;
|
||||
if (question.type === TSurveyQuestionType.PictureSelection) {
|
||||
if (question.type === TSurveyQuestionTypeEnum.PictureSelection) {
|
||||
const selectedChoiceIds = responseValue as string[];
|
||||
answer = question?.choices
|
||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||
@@ -147,7 +147,7 @@ const buildNotionPayloadProperties = (
|
||||
const responses = data.response.data;
|
||||
|
||||
const mappingQIds = mapping
|
||||
.filter((m) => m.question.type === TSurveyQuestionType.PictureSelection)
|
||||
.filter((m) => m.question.type === TSurveyQuestionTypeEnum.PictureSelection)
|
||||
.map((m) => m.question.id);
|
||||
|
||||
Object.keys(responses).forEach((resp) => {
|
||||
|
||||
@@ -14,9 +14,8 @@ import {
|
||||
Rows3Icon,
|
||||
StarIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
TSurveyQuestionType as QuestionId,
|
||||
TSurveyQuestionTypeEnum as QuestionId,
|
||||
TSurveyAddressQuestion,
|
||||
TSurveyCTAQuestion,
|
||||
TSurveyCalQuestion,
|
||||
@@ -28,10 +27,9 @@ import {
|
||||
TSurveyNPSQuestion,
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyPictureSelectionQuestion,
|
||||
TSurveyQuestionType,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyRatingQuestion,
|
||||
} from "@formbricks/types/surveys";
|
||||
|
||||
import { replaceQuestionPresetPlaceholders } from "./templates";
|
||||
|
||||
export type TQuestion = {
|
||||
@@ -232,7 +230,7 @@ export const QUESTIONS_NAME_MAP = questionTypes.reduce(
|
||||
[curr.id]: curr.label,
|
||||
}),
|
||||
{}
|
||||
) as Record<TSurveyQuestionType, string>;
|
||||
) as Record<TSurveyQuestionTypeEnum, string>;
|
||||
|
||||
export const universalQuestionPresets = {
|
||||
required: true,
|
||||
@@ -243,7 +241,7 @@ export const getQuestionDefaults = (id: string, product: any) => {
|
||||
return replaceQuestionPresetPlaceholders(questionType?.preset, product);
|
||||
};
|
||||
|
||||
export const getTSurveyQuestionTypeName = (id: string) => {
|
||||
export const getTSurveyQuestionTypeEnumName = (id: string) => {
|
||||
const questionType = questionTypes.find((questionType) => questionType.id === id);
|
||||
return questionType?.label;
|
||||
};
|
||||
|
||||
@@ -9,14 +9,13 @@ import {
|
||||
QuestionOptions,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
|
||||
|
||||
import {
|
||||
TResponseFilterCriteria,
|
||||
TResponseHiddenFieldsFilter,
|
||||
TSurveyMetaFieldFilter,
|
||||
TSurveyPersonAttributes,
|
||||
} from "@formbricks/types/responses";
|
||||
import { TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
|
||||
@@ -75,8 +74,8 @@ export const generateQuestionAndFilterOptions = (
|
||||
survey.questions.forEach((q) => {
|
||||
if (Object.keys(conditionOptions).includes(q.type)) {
|
||||
if (
|
||||
q.type === TSurveyQuestionType.MultipleChoiceMulti ||
|
||||
q.type === TSurveyQuestionType.MultipleChoiceSingle
|
||||
q.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
|
||||
q.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle
|
||||
) {
|
||||
questionFilterOptions.push({
|
||||
type: q.type,
|
||||
@@ -86,14 +85,14 @@ export const generateQuestionAndFilterOptions = (
|
||||
: [""],
|
||||
id: q.id,
|
||||
});
|
||||
} else if (q.type === TSurveyQuestionType.PictureSelection) {
|
||||
} else if (q.type === TSurveyQuestionTypeEnum.PictureSelection) {
|
||||
questionFilterOptions.push({
|
||||
type: q.type,
|
||||
filterOptions: conditionOptions[q.type],
|
||||
filterComboBoxOptions: q?.choices ? q?.choices?.map((_, idx) => `Picture ${idx + 1}`) : [""],
|
||||
id: q.id,
|
||||
});
|
||||
} else if (q.type === TSurveyQuestionType.Matrix) {
|
||||
} else if (q.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||
questionFilterOptions.push({
|
||||
type: q.type,
|
||||
filterOptions: q.rows.flatMap((row) => Object.values(row)),
|
||||
@@ -268,8 +267,8 @@ export const getFormattedFilters = (
|
||||
questions.forEach(({ filterType, questionType }) => {
|
||||
if (!filters.data) filters.data = {};
|
||||
switch (questionType.questionType) {
|
||||
case TSurveyQuestionType.OpenText:
|
||||
case TSurveyQuestionType.Address: {
|
||||
case TSurveyQuestionTypeEnum.OpenText:
|
||||
case TSurveyQuestionTypeEnum.Address: {
|
||||
if (filterType.filterComboBoxValue === "Filled out") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "submitted",
|
||||
@@ -280,8 +279,8 @@ export const getFormattedFilters = (
|
||||
};
|
||||
}
|
||||
}
|
||||
case TSurveyQuestionType.MultipleChoiceSingle:
|
||||
case TSurveyQuestionType.MultipleChoiceMulti: {
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti: {
|
||||
if (filterType.filterValue === "Includes either") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "includesOne",
|
||||
@@ -294,8 +293,8 @@ export const getFormattedFilters = (
|
||||
};
|
||||
}
|
||||
}
|
||||
case TSurveyQuestionType.NPS:
|
||||
case TSurveyQuestionType.Rating: {
|
||||
case TSurveyQuestionTypeEnum.NPS:
|
||||
case TSurveyQuestionTypeEnum.Rating: {
|
||||
if (filterType.filterValue === "Is equal to") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "equals",
|
||||
@@ -321,7 +320,7 @@ export const getFormattedFilters = (
|
||||
};
|
||||
}
|
||||
}
|
||||
case TSurveyQuestionType.CTA: {
|
||||
case TSurveyQuestionTypeEnum.CTA: {
|
||||
if (filterType.filterComboBoxValue === "Clicked") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "clicked",
|
||||
@@ -332,7 +331,7 @@ export const getFormattedFilters = (
|
||||
};
|
||||
}
|
||||
}
|
||||
case TSurveyQuestionType.Consent: {
|
||||
case TSurveyQuestionTypeEnum.Consent: {
|
||||
if (filterType.filterComboBoxValue === "Accepted") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "accepted",
|
||||
@@ -343,12 +342,12 @@ export const getFormattedFilters = (
|
||||
};
|
||||
}
|
||||
}
|
||||
case TSurveyQuestionType.PictureSelection: {
|
||||
case TSurveyQuestionTypeEnum.PictureSelection: {
|
||||
const questionId = questionType.id ?? "";
|
||||
const question = survey.questions.find((q) => q.id === questionId);
|
||||
|
||||
if (
|
||||
question?.type !== TSurveyQuestionType.PictureSelection ||
|
||||
question?.type !== TSurveyQuestionTypeEnum.PictureSelection ||
|
||||
!Array.isArray(filterType.filterComboBoxValue)
|
||||
) {
|
||||
return;
|
||||
@@ -371,7 +370,7 @@ export const getFormattedFilters = (
|
||||
};
|
||||
}
|
||||
}
|
||||
case TSurveyQuestionType.Matrix: {
|
||||
case TSurveyQuestionTypeEnum.Matrix: {
|
||||
if (
|
||||
filterType.filterValue &&
|
||||
filterType.filterComboBoxValue &&
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys";
|
||||
|
||||
export const getPrefillValue = (
|
||||
@@ -32,10 +32,10 @@ export const checkValidity = (question: TSurveyQuestion, answer: string, languag
|
||||
if (question.required && (!answer || answer === "")) return false;
|
||||
try {
|
||||
switch (question.type) {
|
||||
case TSurveyQuestionType.OpenText: {
|
||||
case TSurveyQuestionTypeEnum.OpenText: {
|
||||
return true;
|
||||
}
|
||||
case TSurveyQuestionType.MultipleChoiceSingle: {
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle: {
|
||||
const hasOther = question.choices[question.choices.length - 1].id === "other";
|
||||
if (!hasOther) {
|
||||
if (!question.choices.find((choice) => choice.label[language] === answer)) return false;
|
||||
@@ -48,7 +48,7 @@ export const checkValidity = (question: TSurveyQuestion, answer: string, languag
|
||||
|
||||
return true;
|
||||
}
|
||||
case TSurveyQuestionType.MultipleChoiceMulti: {
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti: {
|
||||
const answerChoices = answer.split(",");
|
||||
const hasOther = question.choices[question.choices.length - 1].id === "other";
|
||||
if (!hasOther) {
|
||||
@@ -62,7 +62,7 @@ export const checkValidity = (question: TSurveyQuestion, answer: string, languag
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case TSurveyQuestionType.NPS: {
|
||||
case TSurveyQuestionTypeEnum.NPS: {
|
||||
answer = answer.replace(/&/g, ";");
|
||||
const answerNumber = Number(JSON.parse(answer));
|
||||
|
||||
@@ -70,23 +70,23 @@ export const checkValidity = (question: TSurveyQuestion, answer: string, languag
|
||||
if (answerNumber < 0 || answerNumber > 10) return false;
|
||||
return true;
|
||||
}
|
||||
case TSurveyQuestionType.CTA: {
|
||||
case TSurveyQuestionTypeEnum.CTA: {
|
||||
if (question.required && answer === "dismissed") return false;
|
||||
if (answer !== "clicked" && answer !== "dismissed") return false;
|
||||
return true;
|
||||
}
|
||||
case TSurveyQuestionType.Consent: {
|
||||
case TSurveyQuestionTypeEnum.Consent: {
|
||||
if (question.required && answer === "dismissed") return false;
|
||||
if (answer !== "accepted" && answer !== "dismissed") return false;
|
||||
return true;
|
||||
}
|
||||
case TSurveyQuestionType.Rating: {
|
||||
case TSurveyQuestionTypeEnum.Rating: {
|
||||
answer = answer.replace(/&/g, ";");
|
||||
const answerNumber = Number(JSON.parse(answer));
|
||||
if (answerNumber < 1 || answerNumber > question.range) return false;
|
||||
return true;
|
||||
}
|
||||
case TSurveyQuestionType.PictureSelection: {
|
||||
case TSurveyQuestionTypeEnum.PictureSelection: {
|
||||
const answerChoices = answer.split(",");
|
||||
return answerChoices.every((ans: string) => !isNaN(Number(ans)));
|
||||
}
|
||||
@@ -104,20 +104,20 @@ export const transformAnswer = (
|
||||
language: string
|
||||
): string | number | string[] => {
|
||||
switch (question.type) {
|
||||
case TSurveyQuestionType.OpenText:
|
||||
case TSurveyQuestionType.MultipleChoiceSingle:
|
||||
case TSurveyQuestionType.Consent:
|
||||
case TSurveyQuestionType.CTA: {
|
||||
case TSurveyQuestionTypeEnum.OpenText:
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyQuestionTypeEnum.Consent:
|
||||
case TSurveyQuestionTypeEnum.CTA: {
|
||||
return answer;
|
||||
}
|
||||
|
||||
case TSurveyQuestionType.Rating:
|
||||
case TSurveyQuestionType.NPS: {
|
||||
case TSurveyQuestionTypeEnum.Rating:
|
||||
case TSurveyQuestionTypeEnum.NPS: {
|
||||
answer = answer.replace(/&/g, ";");
|
||||
return Number(JSON.parse(answer));
|
||||
}
|
||||
|
||||
case TSurveyQuestionType.PictureSelection: {
|
||||
case TSurveyQuestionTypeEnum.PictureSelection: {
|
||||
const answerChoicesIdx = answer.split(",");
|
||||
const answerArr: string[] = [];
|
||||
|
||||
@@ -130,7 +130,7 @@ export const transformAnswer = (
|
||||
return answerArr.slice(0, 1);
|
||||
}
|
||||
|
||||
case TSurveyQuestionType.MultipleChoiceMulti: {
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti: {
|
||||
let ansArr = answer.split(",");
|
||||
const hasOthers = question.choices[question.choices.length - 1].id === "other";
|
||||
if (!hasOthers) return ansArr;
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
"lucide-react": "^0.379.0",
|
||||
"mime": "^4.0.3",
|
||||
"next": "15.0.0-rc.0",
|
||||
"nodemailer": "^6.9.13",
|
||||
"optional": "^0.1.4",
|
||||
"otplib": "^12.0.1",
|
||||
"papaparse": "^5.4.1",
|
||||
|
||||
@@ -44,8 +44,7 @@
|
||||
},
|
||||
"lint-staged": {
|
||||
"(apps|packages)/**/*.{js,ts,jsx,tsx}": [
|
||||
"prettier --write",
|
||||
"eslint --fix"
|
||||
"prettier --write"
|
||||
],
|
||||
"*.json": [
|
||||
"prettier --write"
|
||||
|
||||
@@ -16,6 +16,6 @@ module.exports = {
|
||||
"^~/(.*)$",
|
||||
"^[./]",
|
||||
],
|
||||
importOrderSeparation: true,
|
||||
importOrderSeparation: false,
|
||||
importOrderSortSpecifiers: true,
|
||||
};
|
||||
|
||||
@@ -5,9 +5,9 @@ import { FingerprintIcon, MonitorSmartphoneIcon, MousePointerClick, TagIcon, Use
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import {
|
||||
import type { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import type { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import type {
|
||||
TBaseFilter,
|
||||
TSegment,
|
||||
TSegmentAttributeFilter,
|
||||
@@ -17,14 +17,14 @@ import { Input } from "@formbricks/ui/Input";
|
||||
import { Modal } from "@formbricks/ui/Modal";
|
||||
import { TabBar } from "@formbricks/ui/TabBar";
|
||||
|
||||
type TAddFilterModalProps = {
|
||||
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";
|
||||
|
||||
@@ -44,7 +44,7 @@ const handleAddFilter = ({
|
||||
attributeClassName?: string;
|
||||
segmentId?: string;
|
||||
deviceType?: string;
|
||||
}) => {
|
||||
}): void => {
|
||||
if (type === "action") {
|
||||
if (!actionClassId) return;
|
||||
|
||||
@@ -54,7 +54,7 @@ const handleAddFilter = ({
|
||||
resource: {
|
||||
id: createId(),
|
||||
root: {
|
||||
type: type,
|
||||
type,
|
||||
actionClassId,
|
||||
},
|
||||
qualifier: {
|
||||
@@ -122,7 +122,7 @@ const handleAddFilter = ({
|
||||
resource: {
|
||||
id: createId(),
|
||||
root: {
|
||||
type: type,
|
||||
type,
|
||||
segmentId,
|
||||
},
|
||||
qualifier: {
|
||||
@@ -145,7 +145,7 @@ const handleAddFilter = ({
|
||||
resource: {
|
||||
id: createId(),
|
||||
root: {
|
||||
type: type,
|
||||
type,
|
||||
deviceType,
|
||||
},
|
||||
qualifier: {
|
||||
@@ -160,19 +160,20 @@ const handleAddFilter = ({
|
||||
}
|
||||
};
|
||||
|
||||
type AttributeTabContentProps = {
|
||||
interface AttributeTabContentProps {
|
||||
attributeClasses: TAttributeClass[];
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
}
|
||||
|
||||
const AttributeTabContent = ({ attributeClasses, onAddFilter, setOpen }: AttributeTabContentProps) => {
|
||||
function AttributeTabContent({ attributeClasses, onAddFilter, setOpen }: AttributeTabContentProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<h2 className="text-base font-medium">Person</h2>
|
||||
<div>
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "person",
|
||||
@@ -180,7 +181,16 @@ const AttributeTabContent = ({ attributeClasses, onAddFilter, setOpen }: Attribu
|
||||
setOpen,
|
||||
});
|
||||
}}
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50">
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "person",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<FingerprintIcon className="h-4 w-4" />
|
||||
<p>userId</p>
|
||||
</div>
|
||||
@@ -192,7 +202,7 @@ const AttributeTabContent = ({ attributeClasses, onAddFilter, setOpen }: Attribu
|
||||
<div>
|
||||
<h2 className="text-base font-medium">Attributes</h2>
|
||||
</div>
|
||||
{attributeClasses?.length === 0 && (
|
||||
{attributeClasses.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 attributes yet!</p>
|
||||
</div>
|
||||
@@ -200,6 +210,8 @@ const AttributeTabContent = ({ attributeClasses, onAddFilter, setOpen }: Attribu
|
||||
{attributeClasses.map((attributeClass) => {
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
key={attributeClass.id}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
@@ -207,8 +219,7 @@ const AttributeTabContent = ({ attributeClasses, onAddFilter, setOpen }: Attribu
|
||||
setOpen,
|
||||
attributeClassName: attributeClass.name,
|
||||
});
|
||||
}}
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50">
|
||||
}}>
|
||||
<TagIcon className="h-4 w-4" />
|
||||
<p>{attributeClass.name}</p>
|
||||
</div>
|
||||
@@ -216,16 +227,16 @@ const AttributeTabContent = ({ attributeClasses, onAddFilter, setOpen }: Attribu
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const AddFilterModal = ({
|
||||
export function AddFilterModal({
|
||||
onAddFilter,
|
||||
open,
|
||||
setOpen,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
segments,
|
||||
}: TAddFilterModalProps) => {
|
||||
}: TAddFilterModalProps) {
|
||||
const [activeTabId, setActiveTabId] = useState("all");
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
|
||||
@@ -241,15 +252,12 @@ export const AddFilterModal = ({
|
||||
{ id: "devices", label: "Devices", icon: <MonitorSmartphoneIcon className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const devices = [
|
||||
{ id: "phone", name: "Phone" },
|
||||
{ id: "desktop", name: "Desktop" },
|
||||
];
|
||||
|
||||
const actionClassesFiltered = useMemo(() => {
|
||||
if (!actionClasses) return [];
|
||||
|
||||
if (!searchValue) return actionClasses;
|
||||
|
||||
return actionClasses.filter((actionClass) =>
|
||||
@@ -313,7 +321,7 @@ export const AddFilterModal = ({
|
||||
const getAllTabContent = () => {
|
||||
return (
|
||||
<>
|
||||
{allFiltersFiltered?.every((filterArr) => {
|
||||
{allFiltersFiltered.every((filterArr) => {
|
||||
return (
|
||||
filterArr.actions.length === 0 &&
|
||||
filterArr.attributes.length === 0 &&
|
||||
@@ -321,11 +329,11 @@ export const AddFilterModal = ({
|
||||
filterArr.devices.length === 0 &&
|
||||
filterArr.personAttributes.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 filters yet!</p>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{allFiltersFiltered.map((filters) => {
|
||||
return (
|
||||
@@ -333,6 +341,8 @@ export const AddFilterModal = ({
|
||||
{filters.actions.map((actionClass) => {
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
key={actionClass.id}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "action",
|
||||
@@ -340,8 +350,7 @@ export const AddFilterModal = ({
|
||||
setOpen,
|
||||
actionClassId: actionClass.id,
|
||||
});
|
||||
}}
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50">
|
||||
}}>
|
||||
<MousePointerClick className="h-4 w-4" />
|
||||
<p>{actionClass.name}</p>
|
||||
</div>
|
||||
@@ -351,6 +360,7 @@ export const AddFilterModal = ({
|
||||
{filters.attributes.map((attributeClass) => {
|
||||
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: "attribute",
|
||||
@@ -358,8 +368,7 @@ export const AddFilterModal = ({
|
||||
setOpen,
|
||||
attributeClassName: attributeClass.name,
|
||||
});
|
||||
}}
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50">
|
||||
}}>
|
||||
<TagIcon className="h-4 w-4" />
|
||||
<p>{attributeClass.name}</p>
|
||||
</div>
|
||||
@@ -369,14 +378,14 @@ export const AddFilterModal = ({
|
||||
{filters.personAttributes.map((personAttribute) => {
|
||||
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: "person",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
});
|
||||
}}
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50">
|
||||
}}>
|
||||
<FingerprintIcon className="h-4 w-4" />
|
||||
<p>{personAttribute.name}</p>
|
||||
</div>
|
||||
@@ -386,6 +395,7 @@ export const AddFilterModal = ({
|
||||
{filters.segments.map((segment) => {
|
||||
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: "segment",
|
||||
@@ -393,8 +403,7 @@ export const AddFilterModal = ({
|
||||
setOpen,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
}}
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50">
|
||||
}}>
|
||||
<Users2Icon className="h-4 w-4" />
|
||||
<p>{segment.title}</p>
|
||||
</div>
|
||||
@@ -403,8 +412,8 @@ export const AddFilterModal = ({
|
||||
|
||||
{filters.devices.map((deviceType) => (
|
||||
<div
|
||||
key={deviceType.id}
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
key={deviceType.id}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "device",
|
||||
@@ -427,7 +436,7 @@ export const AddFilterModal = ({
|
||||
const getActionsTabContent = () => {
|
||||
return (
|
||||
<>
|
||||
{actionClassesFiltered?.length === 0 && (
|
||||
{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>
|
||||
@@ -435,6 +444,7 @@ export const AddFilterModal = ({
|
||||
{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",
|
||||
@@ -442,8 +452,7 @@ export const AddFilterModal = ({
|
||||
setOpen,
|
||||
actionClassId: actionClass.id,
|
||||
});
|
||||
}}
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50">
|
||||
}}>
|
||||
<MousePointerClick className="h-4 w-4" />
|
||||
<p>{actionClass.name}</p>
|
||||
</div>
|
||||
@@ -466,16 +475,17 @@ export const AddFilterModal = ({
|
||||
const getSegmentsTabContent = () => {
|
||||
return (
|
||||
<>
|
||||
{segmentsFiltered?.length === 0 && (
|
||||
{segmentsFiltered.length === 0 && (
|
||||
<div className="flex w-full items-center justify-center gap-4 rounded-lg px-2 py-1 text-sm">
|
||||
<p>You currently have no saved segments.</p>
|
||||
</div>
|
||||
)}
|
||||
{segmentsFiltered
|
||||
?.filter((segment) => !segment.isPrivate)
|
||||
?.map((segment) => {
|
||||
.filter((segment) => !segment.isPrivate)
|
||||
.map((segment) => {
|
||||
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: "segment",
|
||||
@@ -483,8 +493,7 @@ export const AddFilterModal = ({
|
||||
setOpen,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
}}
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50">
|
||||
}}>
|
||||
<Users2Icon className="h-4 w-4" />
|
||||
<p>{segment.title}</p>
|
||||
</div>
|
||||
@@ -499,8 +508,8 @@ export const AddFilterModal = ({
|
||||
<div className="flex flex-col">
|
||||
{deviceTypesFiltered.map((deviceType) => (
|
||||
<div
|
||||
key={deviceType.id}
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
key={deviceType.id}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "device",
|
||||
@@ -542,14 +551,20 @@ export const AddFilterModal = ({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="sm:w-[650px] sm:max-w-full"
|
||||
closeOnOutsideClick
|
||||
hideCloseButton
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
closeOnOutsideClick
|
||||
className="sm:w-[650px] sm:max-w-full">
|
||||
setOpen={setOpen}>
|
||||
<div className="flex w-auto flex-col">
|
||||
<Input placeholder="Browse filters..." autoFocus onChange={(e) => setSearchValue(e.target.value)} />
|
||||
<TabBar className="bg-white" tabs={tabs} activeId={activeTabId} setActiveId={setActiveTabId} />
|
||||
<Input
|
||||
autoFocus
|
||||
onChange={(e) => {
|
||||
setSearchValue(e.target.value);
|
||||
}}
|
||||
placeholder="Browse filters..."
|
||||
/>
|
||||
<TabBar activeId={activeTabId} className="bg-white" setActiveId={setActiveTabId} tabs={tabs} />
|
||||
</div>
|
||||
|
||||
<div className={cn("mt-2 flex max-h-80 flex-col gap-1 overflow-y-auto")}>
|
||||
@@ -557,4 +572,4 @@ export const AddFilterModal = ({
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -9,10 +9,15 @@ import toast from "react-hot-toast";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import { TBaseFilter, TSegment, TSegmentCreateInput, TSegmentUpdateInput } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import type { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import type { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import type {
|
||||
TBaseFilter,
|
||||
TSegment,
|
||||
TSegmentCreateInput,
|
||||
TSegmentUpdateInput,
|
||||
} from "@formbricks/types/segment";
|
||||
import type { TSurvey } from "@formbricks/types/surveys";
|
||||
import { AlertDialog } from "@formbricks/ui/AlertDialog";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { LoadSegmentModal } from "@formbricks/ui/LoadSegmentModal";
|
||||
@@ -28,8 +33,8 @@ import {
|
||||
updateSegmentAction,
|
||||
} from "../lib/actions";
|
||||
import { ACTIONS_TO_EXCLUDE } from "../lib/constants";
|
||||
import { AddFilterModal } from "./AddFilterModal";
|
||||
import { SegmentEditor } from "./SegmentEditor";
|
||||
import { AddFilterModal } from "./add-filter-modal";
|
||||
import { SegmentEditor } from "./segment-editor";
|
||||
|
||||
interface UserTargetingAdvancedCardProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -41,7 +46,7 @@ interface UserTargetingAdvancedCardProps {
|
||||
initialSegment?: TSegment;
|
||||
}
|
||||
|
||||
export const AdvancedTargetingCard = ({
|
||||
export function AdvancedTargetingCard({
|
||||
localSurvey,
|
||||
setLocalSurvey,
|
||||
environmentId,
|
||||
@@ -49,7 +54,7 @@ export const AdvancedTargetingCard = ({
|
||||
attributeClasses,
|
||||
segments,
|
||||
initialSegment,
|
||||
}: UserTargetingAdvancedCardProps) => {
|
||||
}: UserTargetingAdvancedCardProps) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [segment, setSegment] = useState<TSegment | null>(localSurvey.segment);
|
||||
@@ -58,7 +63,7 @@ export const AdvancedTargetingCard = ({
|
||||
const [saveAsNewSegmentModalOpen, setSaveAsNewSegmentModalOpen] = useState(false);
|
||||
const [resetAllFiltersModalOpen, setResetAllFiltersModalOpen] = useState(false);
|
||||
const [loadSegmentModalOpen, setLoadSegmentModalOpen] = useState(false);
|
||||
const [isSegmentEditorOpen, setIsSegmentEditorOpen] = useState(!!localSurvey.segment?.isPrivate);
|
||||
const [isSegmentEditorOpen, setIsSegmentEditorOpen] = useState(Boolean(localSurvey.segment?.isPrivate));
|
||||
const [segmentEditorViewOnly, setSegmentEditorViewOnly] = useState(true);
|
||||
|
||||
const actionClasses = actionClassesProps.filter((actionClass) => {
|
||||
@@ -76,12 +81,12 @@ export const AdvancedTargetingCard = ({
|
||||
useEffect(() => {
|
||||
setLocalSurvey((localSurveyOld) => ({
|
||||
...localSurveyOld,
|
||||
segment: segment,
|
||||
segment,
|
||||
}));
|
||||
}, [setLocalSurvey, segment]);
|
||||
|
||||
const isSegmentUsedInOtherSurveys = useMemo(
|
||||
() => (localSurvey?.segment ? localSurvey.segment?.surveys?.length > 1 : false),
|
||||
() => (localSurvey.segment ? localSurvey.segment.surveys.length > 1 : false),
|
||||
[localSurvey.segment]
|
||||
);
|
||||
|
||||
@@ -97,10 +102,10 @@ export const AdvancedTargetingCard = ({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!!segment && segment?.filters?.length > 0) {
|
||||
if (segment && segment.filters.length > 0) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [segment, segment?.filters?.length]);
|
||||
}, [segment, segment?.filters.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (localSurvey.type === "link") {
|
||||
@@ -110,7 +115,7 @@ export const AdvancedTargetingCard = ({
|
||||
|
||||
const handleAddFilterInGroup = (filter: TBaseFilter) => {
|
||||
const updatedSegment = structuredClone(segment);
|
||||
if (updatedSegment?.filters?.length === 0) {
|
||||
if (updatedSegment?.filters.length === 0) {
|
||||
updatedSegment.filters.push({
|
||||
...filter,
|
||||
connector: null,
|
||||
@@ -144,7 +149,7 @@ export const AdvancedTargetingCard = ({
|
||||
const handleSaveSegment = async (data: TSegmentUpdateInput) => {
|
||||
try {
|
||||
if (!segment) throw new Error("Invalid segment");
|
||||
await updateSegmentAction(environmentId, segment?.id, data);
|
||||
await updateSegmentAction(environmentId, segment.id, data);
|
||||
toast.success("Segment saved successfully");
|
||||
|
||||
setIsSegmentEditorOpen(false);
|
||||
@@ -166,19 +171,23 @@ export const AdvancedTargetingCard = ({
|
||||
return null; // Hide card completely
|
||||
}
|
||||
|
||||
if (!segment) {
|
||||
throw new Error("Survey segment is missing");
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
className="w-full rounded-lg border border-slate-300 bg-white"
|
||||
onOpenChange={setOpen}
|
||||
className="w-full rounded-lg border border-slate-300 bg-white">
|
||||
open={open}>
|
||||
<Collapsible.CollapsibleTrigger
|
||||
asChild
|
||||
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
|
||||
<div className="inline-flex px-4 py-6">
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
<CheckIcon
|
||||
strokeWidth={3}
|
||||
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -194,37 +203,37 @@ export const AdvancedTargetingCard = ({
|
||||
<TargetingIndicator segment={segment} />
|
||||
|
||||
<div className="filter-scrollbar flex flex-col gap-4 overflow-auto rounded-lg border border-slate-300 bg-slate-50 p-4">
|
||||
{!!segment && (
|
||||
{Boolean(segment) && (
|
||||
<LoadSegmentModal
|
||||
open={loadSegmentModalOpen}
|
||||
setOpen={setLoadSegmentModalOpen}
|
||||
surveyId={localSurvey.id}
|
||||
currentSegment={segment}
|
||||
segments={segments}
|
||||
setSegment={setSegment}
|
||||
setIsSegmentEditorOpen={setIsSegmentEditorOpen}
|
||||
onSegmentLoad={handleLoadNewSegment}
|
||||
open={loadSegmentModalOpen}
|
||||
segments={segments}
|
||||
setIsSegmentEditorOpen={setIsSegmentEditorOpen}
|
||||
setOpen={setLoadSegmentModalOpen}
|
||||
setSegment={setSegment}
|
||||
surveyId={localSurvey.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isSegmentEditorOpen ? (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<SegmentTitle
|
||||
title={localSurvey.segment?.title}
|
||||
description={localSurvey.segment?.description}
|
||||
isPrivate={segment?.isPrivate}
|
||||
title={localSurvey.segment?.title}
|
||||
/>
|
||||
{!!segment?.filters?.length && (
|
||||
{Boolean(segment?.filters.length) && (
|
||||
<div className="w-full">
|
||||
<SegmentEditor
|
||||
key={segment.filters.toString()}
|
||||
group={segment.filters}
|
||||
environmentId={environmentId}
|
||||
segment={segment}
|
||||
setSegment={setSegment}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
environmentId={environmentId}
|
||||
group={segment.filters}
|
||||
key={segment.filters.toString()}
|
||||
segment={segment}
|
||||
segments={segments}
|
||||
setSegment={setSegment}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -232,27 +241,30 @@ export const AdvancedTargetingCard = ({
|
||||
<div
|
||||
className={cn(
|
||||
"mt-3 flex items-center gap-2",
|
||||
segment?.isPrivate && !segment?.filters?.length && "mt-0"
|
||||
segment?.isPrivate && !segment.filters.length && "mt-0"
|
||||
)}>
|
||||
<Button variant="secondary" size="sm" onClick={() => setAddFilterModalOpen(true)}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setAddFilterModalOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
variant="secondary">
|
||||
Add filter
|
||||
</Button>
|
||||
|
||||
{isSegmentEditorOpen && !segment?.isPrivate && (
|
||||
{isSegmentEditorOpen && !segment?.isPrivate ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleSaveSegment({ filters: segment?.filters ?? [] });
|
||||
}}>
|
||||
}}
|
||||
size="sm"
|
||||
variant="secondary">
|
||||
Save changes
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{isSegmentEditorOpen && !segment?.isPrivate && (
|
||||
{isSegmentEditorOpen && !segment?.isPrivate ? (
|
||||
<Button
|
||||
variant="minimal"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
setIsSegmentEditorOpen(false);
|
||||
@@ -261,68 +273,68 @@ export const AdvancedTargetingCard = ({
|
||||
if (initialSegment) {
|
||||
setSegment(initialSegment);
|
||||
}
|
||||
}}>
|
||||
}}
|
||||
size="sm"
|
||||
variant="minimal">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<>
|
||||
<AddFilterModal
|
||||
onAddFilter={(filter) => {
|
||||
handleAddFilterInGroup(filter);
|
||||
}}
|
||||
open={addFilterModalOpen}
|
||||
setOpen={setAddFilterModalOpen}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
segments={segments}
|
||||
<AddFilterModal
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
onAddFilter={(filter) => {
|
||||
handleAddFilterInGroup(filter);
|
||||
}}
|
||||
open={addFilterModalOpen}
|
||||
segments={segments}
|
||||
setOpen={setAddFilterModalOpen}
|
||||
/>
|
||||
{Boolean(segment) && (
|
||||
<SaveAsNewSegmentModal
|
||||
localSurvey={localSurvey}
|
||||
onCreateSegment={handleSaveAsNewSegmentCreate}
|
||||
onUpdateSegment={handleSaveAsNewSegmentUpdate}
|
||||
open={saveAsNewSegmentModalOpen}
|
||||
segment={segment}
|
||||
setIsSegmentEditorOpen={setIsSegmentEditorOpen}
|
||||
setOpen={setSaveAsNewSegmentModalOpen}
|
||||
setSegment={setSegment}
|
||||
/>
|
||||
{!!segment && (
|
||||
<SaveAsNewSegmentModal
|
||||
open={saveAsNewSegmentModalOpen}
|
||||
setOpen={setSaveAsNewSegmentModalOpen}
|
||||
localSurvey={localSurvey}
|
||||
segment={segment}
|
||||
setSegment={setSegment}
|
||||
setIsSegmentEditorOpen={setIsSegmentEditorOpen}
|
||||
onCreateSegment={handleSaveAsNewSegmentCreate}
|
||||
onUpdateSegment={handleSaveAsNewSegmentUpdate}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 rounded-lg">
|
||||
<SegmentTitle
|
||||
title={localSurvey.segment?.title}
|
||||
description={localSurvey.segment?.description}
|
||||
isPrivate={segment?.isPrivate}
|
||||
title={localSurvey.segment?.title}
|
||||
/>
|
||||
|
||||
{segmentEditorViewOnly && segment && (
|
||||
{segmentEditorViewOnly && segment ? (
|
||||
<div className="opacity-60">
|
||||
<SegmentEditor
|
||||
key={segment.filters.toString()}
|
||||
group={segment.filters}
|
||||
environmentId={environmentId}
|
||||
segment={segment}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
environmentId={environmentId}
|
||||
group={segment.filters}
|
||||
key={segment.filters.toString()}
|
||||
segment={segment}
|
||||
segments={segments}
|
||||
setSegment={setSegment}
|
||||
viewOnly={segmentEditorViewOnly}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSegmentEditorViewOnly(!segmentEditorViewOnly);
|
||||
}}>
|
||||
}}
|
||||
size="sm"
|
||||
variant="secondary">
|
||||
{segmentEditorViewOnly ? "Hide" : "View"} Filters{" "}
|
||||
{segmentEditorViewOnly ? (
|
||||
<ChevronUpIcon className="ml-2 h-3 w-3" />
|
||||
@@ -331,71 +343,78 @@ export const AdvancedTargetingCard = ({
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isSegmentUsedInOtherSurveys && (
|
||||
<Button variant="secondary" size="sm" onClick={() => handleCloneSegment()}>
|
||||
{isSegmentUsedInOtherSurveys ? (
|
||||
<Button onClick={() => handleCloneSegment()} size="sm" variant="secondary">
|
||||
Clone & Edit Segment
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
{!isSegmentUsedInOtherSurveys && (
|
||||
<Button
|
||||
variant={isSegmentUsedInOtherSurveys ? "minimal" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsSegmentEditorOpen(true);
|
||||
setSegmentEditorViewOnly(false);
|
||||
}}>
|
||||
}}
|
||||
size="sm"
|
||||
variant={isSegmentUsedInOtherSurveys ? "minimal" : "secondary"}>
|
||||
Edit Segment
|
||||
<PencilIcon className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{isSegmentUsedInOtherSurveys && (
|
||||
{isSegmentUsedInOtherSurveys ? (
|
||||
<p className="mt-1 flex items-center text-xs text-slate-500">
|
||||
<AlertCircle className="mr-1 inline h-3 w-3" />
|
||||
This segment is used in other surveys. Make changes{" "}
|
||||
<Link
|
||||
className="ml-1 underline"
|
||||
href={`/environments/${environmentId}/segments`}
|
||||
target="_blank"
|
||||
className="ml-1 underline">
|
||||
target="_blank">
|
||||
here.
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" size="sm" onClick={() => setLoadSegmentModalOpen(true)}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setLoadSegmentModalOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
variant="secondary">
|
||||
Load Segment
|
||||
</Button>
|
||||
|
||||
{!segment?.isPrivate && !!segment?.filters?.length && (
|
||||
<Button variant="secondary" size="sm" onClick={() => setResetAllFiltersModalOpen(true)}>
|
||||
{!segment?.isPrivate && Boolean(segment?.filters.length) && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setResetAllFiltersModalOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
variant="secondary">
|
||||
Reset all filters
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isSegmentEditorOpen && !!segment?.filters?.length && (
|
||||
{isSegmentEditorOpen && Boolean(segment?.filters.length) ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setSaveAsNewSegmentModalOpen(true)}>
|
||||
onClick={() => {
|
||||
setSaveAsNewSegmentModalOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
variant="secondary">
|
||||
Save as new Segment
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<AlertDialog
|
||||
headerText="Are you sure?"
|
||||
open={resetAllFiltersModalOpen}
|
||||
setOpen={setResetAllFiltersModalOpen}
|
||||
mainText="This action resets all filters in this survey."
|
||||
declineBtnLabel="Cancel"
|
||||
onDecline={() => {
|
||||
setResetAllFiltersModalOpen(false);
|
||||
}}
|
||||
confirmBtnLabel="Remove all filters"
|
||||
declineBtnLabel="Cancel"
|
||||
headerText="Are you sure?"
|
||||
mainText="This action resets all filters in this survey."
|
||||
onConfirm={async () => {
|
||||
const segment = await handleResetAllFilters();
|
||||
if (segment) {
|
||||
@@ -407,10 +426,15 @@ export const AdvancedTargetingCard = ({
|
||||
router.refresh();
|
||||
}
|
||||
}}
|
||||
onDecline={() => {
|
||||
setResetAllFiltersModalOpen(false);
|
||||
}}
|
||||
open={resetAllFiltersModalOpen}
|
||||
setOpen={setResetAllFiltersModalOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -6,30 +6,31 @@ import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import { TBaseFilter, TSegment, ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import type { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import type { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import type { TBaseFilter, TSegment } from "@formbricks/types/segment";
|
||||
import { ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Modal } from "@formbricks/ui/Modal";
|
||||
|
||||
import { createSegmentAction } from "../lib/actions";
|
||||
import { AddFilterModal } from "./AddFilterModal";
|
||||
import { SegmentEditor } from "./SegmentEditor";
|
||||
import { AddFilterModal } from "./add-filter-modal";
|
||||
import { SegmentEditor } from "./segment-editor";
|
||||
|
||||
type TCreateSegmentModalProps = {
|
||||
interface TCreateSegmentModalProps {
|
||||
environmentId: string;
|
||||
segments: TSegment[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
actionClasses: TActionClass[];
|
||||
};
|
||||
}
|
||||
|
||||
export const CreateSegmentModal = ({
|
||||
export function CreateSegmentModal({
|
||||
environmentId,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
segments,
|
||||
}: TCreateSegmentModalProps) => {
|
||||
}: TCreateSegmentModalProps) {
|
||||
const router = useRouter();
|
||||
const initialSegmentState = {
|
||||
title: "",
|
||||
@@ -55,13 +56,13 @@ export const CreateSegmentModal = ({
|
||||
|
||||
const handleAddFilterInGroup = (filter: TBaseFilter) => {
|
||||
const updatedSegment = structuredClone(segment);
|
||||
if (updatedSegment?.filters?.length === 0) {
|
||||
if (updatedSegment.filters.length === 0) {
|
||||
updatedSegment.filters.push({
|
||||
...filter,
|
||||
connector: null,
|
||||
});
|
||||
} else {
|
||||
updatedSegment?.filters.push(filter);
|
||||
updatedSegment.filters.push(filter);
|
||||
}
|
||||
|
||||
setSegment(updatedSegment);
|
||||
@@ -121,18 +122,24 @@ export const CreateSegmentModal = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="darkCTA" size="sm" onClick={() => setOpen(true)} EndIcon={PlusIcon}>
|
||||
<Button
|
||||
EndIcon={PlusIcon}
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
variant="darkCTA">
|
||||
Create segment
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
className="md:w-full"
|
||||
closeOnOutsideClick={false}
|
||||
noPadding
|
||||
open={open}
|
||||
setOpen={() => {
|
||||
handleResetState();
|
||||
}}
|
||||
noPadding
|
||||
closeOnOutsideClick={false}
|
||||
className="md:w-full"
|
||||
size="lg">
|
||||
<div className="rounded-lg bg-slate-50">
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
@@ -157,14 +164,14 @@ export const CreateSegmentModal = ({
|
||||
<label className="text-sm font-medium text-slate-900">Title</label>
|
||||
<div className="relative flex flex-col gap-1">
|
||||
<Input
|
||||
placeholder="Ex. Power Users"
|
||||
className="w-auto"
|
||||
onChange={(e) => {
|
||||
setSegment((prev) => ({
|
||||
...prev,
|
||||
title: e.target.value,
|
||||
}));
|
||||
}}
|
||||
className="w-auto"
|
||||
placeholder="Ex. Power Users"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,20 +179,20 @@ export const CreateSegmentModal = ({
|
||||
<div className="flex w-1/2 flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">Description</label>
|
||||
<Input
|
||||
placeholder="Ex. Fully activated recurring users"
|
||||
onChange={(e) => {
|
||||
setSegment((prev) => ({
|
||||
...prev,
|
||||
description: e.target.value,
|
||||
}));
|
||||
}}
|
||||
placeholder="Ex. Fully activated recurring users"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="my-4 text-sm font-medium text-slate-900">Targeting</label>
|
||||
<div className="filter-scrollbar flex w-full flex-col gap-4 overflow-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
{segment?.filters?.length === 0 && (
|
||||
{segment.filters.length === 0 && (
|
||||
<div className="-mb-2 flex items-center gap-1">
|
||||
<FilterIcon className="h-5 w-5 text-slate-700" />
|
||||
<h3 className="text-sm font-medium text-slate-700">Add your first filter to get started</h3>
|
||||
@@ -193,53 +200,55 @@ export const CreateSegmentModal = ({
|
||||
)}
|
||||
|
||||
<SegmentEditor
|
||||
environmentId={environmentId}
|
||||
segment={segment}
|
||||
setSegment={setSegment}
|
||||
group={segment.filters}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
environmentId={environmentId}
|
||||
group={segment.filters}
|
||||
segment={segment}
|
||||
segments={segments}
|
||||
setSegment={setSegment}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-fit"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setAddFilterModalOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
onClick={() => setAddFilterModalOpen(true)}>
|
||||
variant="secondary">
|
||||
Add Filter
|
||||
</Button>
|
||||
|
||||
<AddFilterModal
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
onAddFilter={(filter) => {
|
||||
handleAddFilterInGroup(filter);
|
||||
}}
|
||||
open={addFilterModalOpen}
|
||||
setOpen={setAddFilterModalOpen}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
segments={segments}
|
||||
setOpen={setAddFilterModalOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal"
|
||||
onClick={() => {
|
||||
handleResetState();
|
||||
}}>
|
||||
}}
|
||||
type="button"
|
||||
variant="minimal">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
type="submit"
|
||||
loading={isCreatingSegment}
|
||||
disabled={isSaveDisabled}
|
||||
loading={isCreatingSegment}
|
||||
onClick={() => {
|
||||
handleCreateSegment();
|
||||
}}>
|
||||
}}
|
||||
type="submit"
|
||||
variant="darkCTA">
|
||||
Create segment
|
||||
</Button>
|
||||
</div>
|
||||
@@ -249,4 +258,4 @@ export const CreateSegmentModal = ({
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
266
packages/ee/advanced-targeting/components/segment-editor.tsx
Normal file
266
packages/ee/advanced-targeting/components/segment-editor.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { MoreVertical, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import {
|
||||
addFilterBelow,
|
||||
addFilterInGroup,
|
||||
createGroupFromResource,
|
||||
deleteResource,
|
||||
isResourceFilter,
|
||||
moveResource,
|
||||
toggleGroupConnector,
|
||||
} from "@formbricks/lib/segment/utils";
|
||||
import type { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import type { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import type { TBaseFilter, TBaseFilters, TSegment, TSegmentConnector } from "@formbricks/types/segment";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
|
||||
import { AddFilterModal } from "./add-filter-modal";
|
||||
import { SegmentFilter } from "./segment-filter";
|
||||
|
||||
interface TSegmentEditorProps {
|
||||
group: TBaseFilters;
|
||||
environmentId: string;
|
||||
segment: TSegment;
|
||||
segments: TSegment[];
|
||||
actionClasses: TActionClass[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
setSegment: React.Dispatch<React.SetStateAction<TSegment | null>>;
|
||||
viewOnly?: boolean;
|
||||
}
|
||||
|
||||
export function SegmentEditor({
|
||||
group,
|
||||
environmentId,
|
||||
setSegment,
|
||||
segment,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
segments,
|
||||
viewOnly = false,
|
||||
}: TSegmentEditorProps) {
|
||||
const [addFilterModalOpen, setAddFilterModalOpen] = useState(false);
|
||||
const [addFilterModalOpenedFromBelow, setAddFilterModalOpenedFromBelow] = useState(false);
|
||||
|
||||
const handleAddFilterBelow = (resourceId: string, filter: TBaseFilter) => {
|
||||
const localSegmentCopy = structuredClone(segment);
|
||||
|
||||
if (localSegmentCopy.filters) {
|
||||
addFilterBelow(localSegmentCopy.filters, resourceId, filter);
|
||||
}
|
||||
|
||||
setSegment(localSegmentCopy);
|
||||
};
|
||||
|
||||
const handleCreateGroup = (resourceId: string) => {
|
||||
const localSegmentCopy = structuredClone(segment);
|
||||
if (localSegmentCopy.filters) {
|
||||
createGroupFromResource(localSegmentCopy.filters, resourceId);
|
||||
}
|
||||
|
||||
setSegment(localSegmentCopy);
|
||||
};
|
||||
|
||||
const handleMoveResource = (resourceId: string, direction: "up" | "down") => {
|
||||
const localSegmentCopy = structuredClone(segment);
|
||||
if (localSegmentCopy.filters) {
|
||||
moveResource(localSegmentCopy.filters, resourceId, direction);
|
||||
}
|
||||
|
||||
setSegment(localSegmentCopy);
|
||||
};
|
||||
|
||||
const handleDeleteResource = (resourceId: string) => {
|
||||
const localSegmentCopy = structuredClone(segment);
|
||||
|
||||
if (localSegmentCopy.filters) {
|
||||
deleteResource(localSegmentCopy.filters, resourceId);
|
||||
}
|
||||
|
||||
setSegment(localSegmentCopy);
|
||||
};
|
||||
|
||||
const handleToggleGroupConnector = (groupId: string, newConnectorValue: TSegmentConnector) => {
|
||||
const localSegmentCopy = structuredClone(segment);
|
||||
if (localSegmentCopy.filters) {
|
||||
toggleGroupConnector(localSegmentCopy.filters, groupId, newConnectorValue);
|
||||
}
|
||||
|
||||
setSegment(localSegmentCopy);
|
||||
};
|
||||
|
||||
const onConnectorChange = (groupId: string, connector: TSegmentConnector) => {
|
||||
if (!connector) return;
|
||||
|
||||
if (connector === "and") {
|
||||
handleToggleGroupConnector(groupId, "or");
|
||||
} else {
|
||||
handleToggleGroupConnector(groupId, "and");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddFilterInGroup = (groupId: string, filter: TBaseFilter) => {
|
||||
const localSegmentCopy = structuredClone(segment);
|
||||
|
||||
if (localSegmentCopy.filters) {
|
||||
addFilterInGroup(localSegmentCopy.filters, groupId, filter);
|
||||
}
|
||||
setSegment(localSegmentCopy);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg">
|
||||
{group.map((groupItem) => {
|
||||
const { connector, resource, id: groupId } = groupItem;
|
||||
|
||||
if (isResourceFilter(resource)) {
|
||||
return (
|
||||
<SegmentFilter
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
connector={connector}
|
||||
environmentId={environmentId}
|
||||
handleAddFilterBelow={handleAddFilterBelow}
|
||||
key={groupId}
|
||||
onCreateGroup={(filterId: string) => {
|
||||
handleCreateGroup(filterId);
|
||||
}}
|
||||
onDeleteFilter={(filterId: string) => {
|
||||
handleDeleteResource(filterId);
|
||||
}}
|
||||
onMoveFilter={(filterId: string, direction: "up" | "down") => {
|
||||
handleMoveResource(filterId, direction);
|
||||
}}
|
||||
resource={resource}
|
||||
segment={segment}
|
||||
segments={segments}
|
||||
setSegment={setSegment}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={groupId}>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-auto" key={connector}>
|
||||
<span
|
||||
className={cn(
|
||||
Boolean(connector) && "cursor-pointer underline",
|
||||
"text-sm",
|
||||
viewOnly && "cursor-not-allowed"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (viewOnly) return;
|
||||
onConnectorChange(groupId, connector);
|
||||
}}>
|
||||
{connector ? connector : "Where"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-slate-300 bg-white p-4">
|
||||
<SegmentEditor
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
environmentId={environmentId}
|
||||
group={resource}
|
||||
segment={segment}
|
||||
segments={segments}
|
||||
setSegment={setSegment}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
disabled={viewOnly}
|
||||
onClick={() => {
|
||||
if (viewOnly) return;
|
||||
setAddFilterModalOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
variant="secondary">
|
||||
Add filter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AddFilterModal
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
onAddFilter={(filter) => {
|
||||
if (addFilterModalOpenedFromBelow) {
|
||||
handleAddFilterBelow(groupId, filter);
|
||||
setAddFilterModalOpenedFromBelow(false);
|
||||
} else {
|
||||
handleAddFilterInGroup(groupId, filter);
|
||||
}
|
||||
}}
|
||||
open={addFilterModalOpen}
|
||||
segments={segments}
|
||||
setOpen={setAddFilterModalOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger disabled={viewOnly}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setAddFilterModalOpenedFromBelow(true);
|
||||
setAddFilterModalOpen(true);
|
||||
}}>
|
||||
Add filter below
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleCreateGroup(groupId);
|
||||
}}>
|
||||
Create group
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleMoveResource(groupId, "up");
|
||||
}}>
|
||||
Move up
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (viewOnly) return;
|
||||
handleMoveResource(groupId, "down");
|
||||
}}>
|
||||
Move down
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
className="p-0"
|
||||
disabled={viewOnly}
|
||||
onClick={() => {
|
||||
if (viewOnly) return;
|
||||
handleDeleteResource(groupId);
|
||||
}}
|
||||
variant="minimal">
|
||||
<Trash2 className={cn("h-4 w-4 cursor-pointer", viewOnly && "cursor-not-allowed")} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,15 +27,9 @@ import {
|
||||
updateSegmentIdInFilter,
|
||||
} from "@formbricks/lib/segment/utils";
|
||||
import { isCapitalized } from "@formbricks/lib/utils/strings";
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import {
|
||||
ACTION_METRICS,
|
||||
ARITHMETIC_OPERATORS,
|
||||
ATTRIBUTE_OPERATORS,
|
||||
BASE_OPERATORS,
|
||||
DEVICE_OPERATORS,
|
||||
PERSON_OPERATORS,
|
||||
import type { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import type { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import type {
|
||||
TActionMetric,
|
||||
TArithmeticOperator,
|
||||
TAttributeOperator,
|
||||
@@ -53,6 +47,14 @@ import {
|
||||
TSegmentPersonFilter,
|
||||
TSegmentSegmentFilter,
|
||||
} from "@formbricks/types/segment";
|
||||
import {
|
||||
ACTION_METRICS,
|
||||
ARITHMETIC_OPERATORS,
|
||||
ATTRIBUTE_OPERATORS,
|
||||
BASE_OPERATORS,
|
||||
DEVICE_OPERATORS,
|
||||
PERSON_OPERATORS,
|
||||
} from "@formbricks/types/segment";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -63,9 +65,9 @@ import {
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
|
||||
|
||||
import { AddFilterModal } from "./AddFilterModal";
|
||||
import { AddFilterModal } from "./add-filter-modal";
|
||||
|
||||
type TSegmentFilterProps = {
|
||||
interface TSegmentFilterProps {
|
||||
connector: TSegmentConnector;
|
||||
resource: TSegmentFilter;
|
||||
environmentId: string;
|
||||
@@ -79,9 +81,9 @@ type TSegmentFilterProps = {
|
||||
onDeleteFilter: (filterId: string) => void;
|
||||
onMoveFilter: (filterId: string, direction: "up" | "down") => void;
|
||||
viewOnly?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const SegmentFilterItemConnector = ({
|
||||
function SegmentFilterItemConnector({
|
||||
connector,
|
||||
segment,
|
||||
setSegment,
|
||||
@@ -93,7 +95,7 @@ const SegmentFilterItemConnector = ({
|
||||
setSegment: (segment: TSegment) => void;
|
||||
filterId: string;
|
||||
viewOnly?: boolean;
|
||||
}) => {
|
||||
}) {
|
||||
const updateLocalSurvey = (newConnector: TSegmentConnector) => {
|
||||
const updatedSegment = structuredClone(segment);
|
||||
if (updatedSegment.filters) {
|
||||
@@ -116,18 +118,18 @@ const SegmentFilterItemConnector = ({
|
||||
return (
|
||||
<div className="w-[40px]">
|
||||
<span
|
||||
className={cn(!!connector && "cursor-pointer underline", viewOnly && "cursor-not-allowed")}
|
||||
className={cn(Boolean(connector) && "cursor-pointer underline", viewOnly && "cursor-not-allowed")}
|
||||
onClick={() => {
|
||||
if (viewOnly) return;
|
||||
onConnectorChange();
|
||||
}}>
|
||||
{!!connector ? connector : "Where"}
|
||||
{connector ? connector : "Where"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const SegmentFilterItemContextMenu = ({
|
||||
function SegmentFilterItemContextMenu({
|
||||
filterId,
|
||||
onAddFilterBelow,
|
||||
onCreateGroup,
|
||||
@@ -141,7 +143,7 @@ const SegmentFilterItemContextMenu = ({
|
||||
onDeleteFilter: (filterId: string) => void;
|
||||
onMoveFilter: (filterId: string, direction: "up" | "down") => void;
|
||||
viewOnly?: boolean;
|
||||
}) => {
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
@@ -150,27 +152,47 @@ const SegmentFilterItemContextMenu = ({
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => onAddFilterBelow()}>Add filter below</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onAddFilterBelow();
|
||||
}}>
|
||||
Add filter below
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => onCreateGroup(filterId)}>Create group</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onMoveFilter(filterId, "up")}>Move up</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onMoveFilter(filterId, "down")}>Move down</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onCreateGroup(filterId);
|
||||
}}>
|
||||
Create group
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onMoveFilter(filterId, "up");
|
||||
}}>
|
||||
Move up
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onMoveFilter(filterId, "down");
|
||||
}}>
|
||||
Move down
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="minimal"
|
||||
className="mr-4 p-0"
|
||||
disabled={viewOnly}
|
||||
onClick={() => {
|
||||
if (viewOnly) return;
|
||||
onDeleteFilter(filterId);
|
||||
}}>
|
||||
<Trash2 className={cn("h-4 w-4 cursor-pointer", viewOnly && "cursor-not-allowed")}></Trash2>
|
||||
}}
|
||||
variant="minimal">
|
||||
<Trash2 className={cn("h-4 w-4 cursor-pointer", viewOnly && "cursor-not-allowed")} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
type TAttributeSegmentFilterProps = TSegmentFilterProps & {
|
||||
onAddFilterBelow: () => void;
|
||||
@@ -178,7 +200,7 @@ type TAttributeSegmentFilterProps = TSegmentFilterProps & {
|
||||
updateValueInLocalSurvey: (filterId: string, newValue: TSegmentFilterValue) => void;
|
||||
};
|
||||
|
||||
const AttributeSegmentFilter = ({
|
||||
function AttributeSegmentFilter({
|
||||
connector,
|
||||
resource,
|
||||
onAddFilterBelow,
|
||||
@@ -190,7 +212,7 @@ const AttributeSegmentFilter = ({
|
||||
setSegment,
|
||||
attributeClasses,
|
||||
viewOnly,
|
||||
}: TAttributeSegmentFilterProps) => {
|
||||
}: TAttributeSegmentFilterProps) {
|
||||
const { attributeClassName } = resource.root;
|
||||
const operatorText = convertOperatorToText(resource.qualifier.operator);
|
||||
|
||||
@@ -218,7 +240,7 @@ const AttributeSegmentFilter = ({
|
||||
};
|
||||
});
|
||||
|
||||
const attributeClass = attributeClasses?.find((attrClass) => attrClass?.name === attributeClassName)?.name;
|
||||
const attributeClass = attributeClasses.find((attrClass) => attrClass.name === attributeClassName)?.name;
|
||||
|
||||
const updateOperatorInLocalSurvey = (filterId: string, newOperator: TAttributeOperator) => {
|
||||
const updatedSegment = structuredClone(segment);
|
||||
@@ -270,20 +292,20 @@ const AttributeSegmentFilter = ({
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<SegmentFilterItemConnector
|
||||
key={connector}
|
||||
connector={connector}
|
||||
filterId={resource.id}
|
||||
setSegment={setSegment}
|
||||
key={connector}
|
||||
segment={segment}
|
||||
setSegment={setSegment}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={attributeClass}
|
||||
disabled={viewOnly}
|
||||
onValueChange={(value) => {
|
||||
updateAttributeClassNameInLocalSurvey(resource.id, value);
|
||||
}}
|
||||
disabled={viewOnly}>
|
||||
value={attributeClass}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
|
||||
hideArrow>
|
||||
@@ -298,9 +320,9 @@ const AttributeSegmentFilter = ({
|
||||
|
||||
<SelectContent>
|
||||
{attributeClasses
|
||||
?.filter((attributeClass) => !attributeClass.archived)
|
||||
?.map((attrClass) => (
|
||||
<SelectItem value={attrClass.name} key={attrClass.id}>
|
||||
.filter((attributeClass) => !attributeClass.archived)
|
||||
.map((attrClass) => (
|
||||
<SelectItem key={attrClass.id} value={attrClass.name}>
|
||||
{attrClass.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -308,11 +330,11 @@ const AttributeSegmentFilter = ({
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={operatorText}
|
||||
disabled={viewOnly}
|
||||
onValueChange={(operator: TAttributeOperator) => {
|
||||
updateOperatorInLocalSurvey(resource.id, operator);
|
||||
}}
|
||||
disabled={viewOnly}>
|
||||
value={operatorText}>
|
||||
<SelectTrigger className="flex w-auto items-center justify-center bg-white text-center" hideArrow>
|
||||
<SelectValue>
|
||||
<p>{operatorText}</p>
|
||||
@@ -321,7 +343,7 @@ const AttributeSegmentFilter = ({
|
||||
|
||||
<SelectContent>
|
||||
{operatorArr.map((operator) => (
|
||||
<SelectItem value={operator.id} title={convertOperatorToTitle(operator.id)}>
|
||||
<SelectItem title={convertOperatorToTitle(operator.id)} value={operator.id}>
|
||||
{operator.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -331,20 +353,20 @@ const AttributeSegmentFilter = ({
|
||||
{!["isSet", "isNotSet"].includes(resource.qualifier.operator) && (
|
||||
<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}
|
||||
value={resource.value}
|
||||
onChange={(e) => {
|
||||
if (viewOnly) return;
|
||||
checkValueAndUpdate(e);
|
||||
}}
|
||||
className={cn("w-auto bg-white", valueError && "border border-red-500 focus:border-red-500")}
|
||||
value={resource.value}
|
||||
/>
|
||||
|
||||
{valueError && (
|
||||
{valueError ? (
|
||||
<p className="absolute right-2 -mt-1 rounded-md bg-white px-2 text-xs text-red-500">
|
||||
{valueError}
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -358,7 +380,7 @@ const AttributeSegmentFilter = ({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
type TPersonSegmentFilterProps = TSegmentFilterProps & {
|
||||
onAddFilterBelow: () => void;
|
||||
@@ -366,7 +388,7 @@ type TPersonSegmentFilterProps = TSegmentFilterProps & {
|
||||
updateValueInLocalSurvey: (filterId: string, newValue: TSegmentFilterValue) => void;
|
||||
};
|
||||
|
||||
const PersonSegmentFilter = ({
|
||||
function PersonSegmentFilter({
|
||||
connector,
|
||||
resource,
|
||||
onAddFilterBelow,
|
||||
@@ -377,7 +399,7 @@ const PersonSegmentFilter = ({
|
||||
segment,
|
||||
setSegment,
|
||||
viewOnly,
|
||||
}: TPersonSegmentFilterProps) => {
|
||||
}: TPersonSegmentFilterProps) {
|
||||
const { personIdentifier } = resource.root;
|
||||
const operatorText = convertOperatorToText(resource.qualifier.operator);
|
||||
|
||||
@@ -455,20 +477,20 @@ const PersonSegmentFilter = ({
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<SegmentFilterItemConnector
|
||||
key={connector}
|
||||
connector={connector}
|
||||
filterId={resource.id}
|
||||
setSegment={setSegment}
|
||||
key={connector}
|
||||
segment={segment}
|
||||
setSegment={setSegment}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={personIdentifier}
|
||||
disabled={viewOnly}
|
||||
onValueChange={(value) => {
|
||||
updatePersonIdentifierInLocalSurvey(resource.id, value);
|
||||
}}
|
||||
disabled={viewOnly}>
|
||||
value={personIdentifier}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
|
||||
hideArrow>
|
||||
@@ -481,18 +503,18 @@ const PersonSegmentFilter = ({
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value={personIdentifier} key={personIdentifier}>
|
||||
<SelectItem key={personIdentifier} value={personIdentifier}>
|
||||
{personIdentifier}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={operatorText}
|
||||
disabled={viewOnly}
|
||||
onValueChange={(operator: TAttributeOperator) => {
|
||||
updateOperatorInLocalSurvey(resource.id, operator);
|
||||
}}
|
||||
disabled={viewOnly}>
|
||||
value={operatorText}>
|
||||
<SelectTrigger className="flex w-auto items-center justify-center bg-white text-center" hideArrow>
|
||||
<SelectValue>
|
||||
<p>{operatorText}</p>
|
||||
@@ -501,7 +523,7 @@ const PersonSegmentFilter = ({
|
||||
|
||||
<SelectContent>
|
||||
{operatorArr.map((operator) => (
|
||||
<SelectItem value={operator.id} title={convertOperatorToTitle(operator.id)}>
|
||||
<SelectItem title={convertOperatorToTitle(operator.id)} value={operator.id}>
|
||||
{operator.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -511,20 +533,20 @@ const PersonSegmentFilter = ({
|
||||
{!["isSet", "isNotSet"].includes(resource.qualifier.operator) && (
|
||||
<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}
|
||||
value={resource.value}
|
||||
onChange={(e) => {
|
||||
if (viewOnly) return;
|
||||
checkValueAndUpdate(e);
|
||||
}}
|
||||
className={cn("w-auto bg-white", valueError && "border border-red-500 focus:border-red-500")}
|
||||
value={resource.value}
|
||||
/>
|
||||
|
||||
{valueError && (
|
||||
{valueError ? (
|
||||
<p className="absolute right-2 -mt-1 rounded-md bg-white px-2 text-xs text-red-500">
|
||||
{valueError}
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -538,14 +560,14 @@ const PersonSegmentFilter = ({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
type TActionSegmentFilterProps = TSegmentFilterProps & {
|
||||
onAddFilterBelow: () => void;
|
||||
resource: TSegmentActionFilter;
|
||||
updateValueInLocalSurvey: (filterId: string, newValue: TSegmentFilterValue) => void;
|
||||
};
|
||||
const ActionSegmentFilter = ({
|
||||
function ActionSegmentFilter({
|
||||
connector,
|
||||
resource,
|
||||
segment,
|
||||
@@ -557,7 +579,7 @@ const ActionSegmentFilter = ({
|
||||
updateValueInLocalSurvey,
|
||||
actionClasses,
|
||||
viewOnly,
|
||||
}: TActionSegmentFilterProps) => {
|
||||
}: TActionSegmentFilterProps) {
|
||||
const { actionClassId } = resource.root;
|
||||
const operatorText = convertOperatorToText(resource.qualifier.operator);
|
||||
const qualifierMetric = resource.qualifier.metric;
|
||||
@@ -626,20 +648,20 @@ const ActionSegmentFilter = ({
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<SegmentFilterItemConnector
|
||||
key={connector}
|
||||
connector={connector}
|
||||
filterId={resource.id}
|
||||
key={connector}
|
||||
segment={segment}
|
||||
setSegment={setSegment}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={actionClass}
|
||||
disabled={viewOnly}
|
||||
onValueChange={(value) => {
|
||||
updateActionClassIdInSegment(resource.id, value);
|
||||
}}
|
||||
disabled={viewOnly}>
|
||||
value={actionClass}>
|
||||
<SelectTrigger
|
||||
className="w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
|
||||
hideArrow>
|
||||
@@ -658,11 +680,11 @@ const ActionSegmentFilter = ({
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={qualifierMetric}
|
||||
disabled={viewOnly}
|
||||
onValueChange={(value: TActionMetric) => {
|
||||
updateActionMetricInLocalSurvey(resource.id, value);
|
||||
}}
|
||||
disabled={viewOnly}>
|
||||
value={qualifierMetric}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
|
||||
hideArrow>
|
||||
@@ -677,11 +699,11 @@ const ActionSegmentFilter = ({
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={operatorText}
|
||||
disabled={viewOnly}
|
||||
onValueChange={(operator: TBaseOperator) => {
|
||||
updateOperatorInSegment(resource.id, operator);
|
||||
}}
|
||||
disabled={viewOnly}>
|
||||
value={operatorText}>
|
||||
<SelectTrigger
|
||||
className="flex w-full max-w-[40px] items-center justify-center bg-white text-center"
|
||||
hideArrow>
|
||||
@@ -692,7 +714,7 @@ const ActionSegmentFilter = ({
|
||||
|
||||
<SelectContent>
|
||||
{operatorArr.map((operator) => (
|
||||
<SelectItem value={operator.id} title={convertOperatorToTitle(operator.id)}>
|
||||
<SelectItem title={convertOperatorToTitle(operator.id)} value={operator.id}>
|
||||
{operator.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -701,18 +723,18 @@ const ActionSegmentFilter = ({
|
||||
|
||||
<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}
|
||||
value={resource.value}
|
||||
onChange={(e) => {
|
||||
if (viewOnly) return;
|
||||
checkValueAndUpdate(e);
|
||||
}}
|
||||
className={cn("w-auto bg-white", valueError && "border border-red-500 focus:border-red-500")}
|
||||
value={resource.value}
|
||||
/>
|
||||
|
||||
{valueError && (
|
||||
{valueError ? (
|
||||
<p className="absolute right-2 -mt-1 rounded-md bg-white px-2 text-xs text-red-500">{valueError}</p>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<SegmentFilterItemContextMenu
|
||||
@@ -725,13 +747,13 @@ const ActionSegmentFilter = ({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
type TSegmentSegmentFilterProps = TSegmentFilterProps & {
|
||||
onAddFilterBelow: () => void;
|
||||
resource: TSegmentSegmentFilter;
|
||||
};
|
||||
const SegmentSegmentFilter = ({
|
||||
function SegmentSegmentFilter({
|
||||
connector,
|
||||
onAddFilterBelow,
|
||||
onCreateGroup,
|
||||
@@ -742,11 +764,11 @@ const SegmentSegmentFilter = ({
|
||||
segments,
|
||||
setSegment,
|
||||
viewOnly,
|
||||
}: TSegmentSegmentFilterProps) => {
|
||||
}: TSegmentSegmentFilterProps) {
|
||||
const { segmentId } = resource.root;
|
||||
const operatorText = convertOperatorToText(resource.qualifier.operator);
|
||||
|
||||
const currentSegment = segments?.find((segment) => segment.id === segmentId);
|
||||
const currentSegment = segments.find((segment) => segment.id === segmentId);
|
||||
|
||||
const updateOperatorInSegment = (filterId: string, newOperator: TSegmentOperator) => {
|
||||
const updatedSegment = structuredClone(segment);
|
||||
@@ -780,9 +802,9 @@ const SegmentSegmentFilter = ({
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<SegmentFilterItemConnector
|
||||
key={connector}
|
||||
connector={connector}
|
||||
filterId={resource.id}
|
||||
key={connector}
|
||||
segment={segment}
|
||||
setSegment={setSegment}
|
||||
viewOnly={viewOnly}
|
||||
@@ -800,11 +822,11 @@ const SegmentSegmentFilter = ({
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={currentSegment?.id}
|
||||
disabled={viewOnly}
|
||||
onValueChange={(value) => {
|
||||
updateSegmentIdInSegment(resource.id, value);
|
||||
}}
|
||||
disabled={viewOnly}>
|
||||
value={currentSegment?.id}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
|
||||
hideArrow>
|
||||
@@ -816,8 +838,10 @@ const SegmentSegmentFilter = ({
|
||||
|
||||
<SelectContent>
|
||||
{segments
|
||||
?.filter((segment) => !segment.isPrivate)
|
||||
.map((segment) => <SelectItem value={segment.id}>{segment.title}</SelectItem>)}
|
||||
.filter((segment) => !segment.isPrivate)
|
||||
.map((segment) => (
|
||||
<SelectItem value={segment.id}>{segment.title}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -831,13 +855,13 @@ const SegmentSegmentFilter = ({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
type TDeviceFilterProps = TSegmentFilterProps & {
|
||||
onAddFilterBelow: () => void;
|
||||
resource: TSegmentDeviceFilter;
|
||||
};
|
||||
const DeviceFilter = ({
|
||||
function DeviceFilter({
|
||||
connector,
|
||||
onAddFilterBelow,
|
||||
onCreateGroup,
|
||||
@@ -847,7 +871,7 @@ const DeviceFilter = ({
|
||||
segment,
|
||||
setSegment,
|
||||
viewOnly,
|
||||
}: TDeviceFilterProps) => {
|
||||
}: TDeviceFilterProps) {
|
||||
const { value } = resource;
|
||||
|
||||
const operatorText = convertOperatorToText(resource.qualifier.operator);
|
||||
@@ -877,9 +901,9 @@ const DeviceFilter = ({
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<SegmentFilterItemConnector
|
||||
key={connector}
|
||||
connector={connector}
|
||||
filterId={resource.id}
|
||||
key={connector}
|
||||
segment={segment}
|
||||
setSegment={setSegment}
|
||||
viewOnly={viewOnly}
|
||||
@@ -891,11 +915,11 @@ const DeviceFilter = ({
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={operatorText}
|
||||
disabled={viewOnly}
|
||||
onValueChange={(operator: TDeviceOperator) => {
|
||||
updateOperatorInSegment(resource.id, operator);
|
||||
}}
|
||||
disabled={viewOnly}>
|
||||
value={operatorText}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto max-w-[40px] items-center justify-center bg-white text-center"
|
||||
hideArrow>
|
||||
@@ -912,11 +936,11 @@ const DeviceFilter = ({
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={value as "phone" | "desktop"}
|
||||
disabled={viewOnly}
|
||||
onValueChange={(value: "phone" | "desktop") => {
|
||||
updateValueInSegment(resource.id, value);
|
||||
}}
|
||||
disabled={viewOnly}>
|
||||
value={value as "phone" | "desktop"}>
|
||||
<SelectTrigger className="flex w-auto items-center justify-center bg-white text-center" hideArrow>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -941,9 +965,9 @@ const DeviceFilter = ({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const SegmentFilter = ({
|
||||
export function SegmentFilter({
|
||||
resource,
|
||||
connector,
|
||||
environmentId,
|
||||
@@ -957,7 +981,7 @@ export const SegmentFilter = ({
|
||||
onDeleteFilter,
|
||||
onMoveFilter,
|
||||
viewOnly = false,
|
||||
}: TSegmentFilterProps) => {
|
||||
}: TSegmentFilterProps) {
|
||||
const [addFilterModalOpen, setAddFilterModalOpen] = useState(false);
|
||||
const updateFilterValueInSegment = (filterId: string, newValue: string | number) => {
|
||||
const updatedSegment = structuredClone(segment);
|
||||
@@ -972,35 +996,39 @@ export const SegmentFilter = ({
|
||||
setAddFilterModalOpen(true);
|
||||
};
|
||||
|
||||
const RenderFilterModal = () => (
|
||||
<AddFilterModal
|
||||
open={addFilterModalOpen}
|
||||
setOpen={setAddFilterModalOpen}
|
||||
onAddFilter={(filter) => handleAddFilterBelow(resource.id, filter)}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
segments={segments}
|
||||
/>
|
||||
);
|
||||
function RenderFilterModal() {
|
||||
return (
|
||||
<AddFilterModal
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
onAddFilter={(filter) => {
|
||||
handleAddFilterBelow(resource.id, filter);
|
||||
}}
|
||||
open={addFilterModalOpen}
|
||||
segments={segments}
|
||||
setOpen={setAddFilterModalOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (resource.root.type) {
|
||||
case "action":
|
||||
return (
|
||||
<>
|
||||
<ActionSegmentFilter
|
||||
connector={connector}
|
||||
resource={resource as TSegmentActionFilter}
|
||||
environmentId={environmentId}
|
||||
segment={segment}
|
||||
segments={segments}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
setSegment={setSegment}
|
||||
onAddFilterBelow={onAddFilterBelow}
|
||||
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}
|
||||
/>
|
||||
@@ -1013,19 +1041,19 @@ export const SegmentFilter = ({
|
||||
return (
|
||||
<>
|
||||
<AttributeSegmentFilter
|
||||
connector={connector}
|
||||
resource={resource as TSegmentAttributeFilter}
|
||||
environmentId={environmentId}
|
||||
segment={segment}
|
||||
segments={segments}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
setSegment={setSegment}
|
||||
onAddFilterBelow={onAddFilterBelow}
|
||||
connector={connector}
|
||||
environmentId={environmentId}
|
||||
handleAddFilterBelow={handleAddFilterBelow}
|
||||
onAddFilterBelow={onAddFilterBelow}
|
||||
onCreateGroup={onCreateGroup}
|
||||
onDeleteFilter={onDeleteFilter}
|
||||
onMoveFilter={onMoveFilter}
|
||||
resource={resource as TSegmentAttributeFilter}
|
||||
segment={segment}
|
||||
segments={segments}
|
||||
setSegment={setSegment}
|
||||
updateValueInLocalSurvey={updateFilterValueInSegment}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
@@ -1038,19 +1066,19 @@ export const SegmentFilter = ({
|
||||
return (
|
||||
<>
|
||||
<PersonSegmentFilter
|
||||
connector={connector}
|
||||
resource={resource as TSegmentPersonFilter}
|
||||
environmentId={environmentId}
|
||||
segment={segment}
|
||||
segments={segments}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
setSegment={setSegment}
|
||||
onAddFilterBelow={onAddFilterBelow}
|
||||
connector={connector}
|
||||
environmentId={environmentId}
|
||||
handleAddFilterBelow={handleAddFilterBelow}
|
||||
onAddFilterBelow={onAddFilterBelow}
|
||||
onCreateGroup={onCreateGroup}
|
||||
onDeleteFilter={onDeleteFilter}
|
||||
onMoveFilter={onMoveFilter}
|
||||
resource={resource as TSegmentPersonFilter}
|
||||
segment={segment}
|
||||
segments={segments}
|
||||
setSegment={setSegment}
|
||||
updateValueInLocalSurvey={updateFilterValueInSegment}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
@@ -1063,19 +1091,19 @@ export const SegmentFilter = ({
|
||||
return (
|
||||
<>
|
||||
<SegmentSegmentFilter
|
||||
connector={connector}
|
||||
resource={resource as TSegmentSegmentFilter}
|
||||
environmentId={environmentId}
|
||||
segment={segment}
|
||||
segments={segments}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
setSegment={setSegment}
|
||||
onAddFilterBelow={onAddFilterBelow}
|
||||
connector={connector}
|
||||
environmentId={environmentId}
|
||||
handleAddFilterBelow={handleAddFilterBelow}
|
||||
onAddFilterBelow={onAddFilterBelow}
|
||||
onCreateGroup={onCreateGroup}
|
||||
onDeleteFilter={onDeleteFilter}
|
||||
onMoveFilter={onMoveFilter}
|
||||
resource={resource as TSegmentSegmentFilter}
|
||||
segment={segment}
|
||||
segments={segments}
|
||||
setSegment={setSegment}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
|
||||
@@ -1087,19 +1115,19 @@ export const SegmentFilter = ({
|
||||
return (
|
||||
<>
|
||||
<DeviceFilter
|
||||
connector={connector}
|
||||
resource={resource as TSegmentDeviceFilter}
|
||||
environmentId={environmentId}
|
||||
segment={segment}
|
||||
segments={segments}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
setSegment={setSegment}
|
||||
onAddFilterBelow={onAddFilterBelow}
|
||||
connector={connector}
|
||||
environmentId={environmentId}
|
||||
handleAddFilterBelow={handleAddFilterBelow}
|
||||
onAddFilterBelow={onAddFilterBelow}
|
||||
onCreateGroup={onCreateGroup}
|
||||
onDeleteFilter={onDeleteFilter}
|
||||
onMoveFilter={onMoveFilter}
|
||||
resource={resource as TSegmentDeviceFilter}
|
||||
segment={segment}
|
||||
segments={segments}
|
||||
setSegment={setSegment}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
|
||||
@@ -1110,4 +1138,4 @@ export const SegmentFilter = ({
|
||||
default:
|
||||
return <div>Unknown filter type</div>;
|
||||
}
|
||||
};
|
||||
}
|
||||
252
packages/ee/advanced-targeting/components/segment-settings.tsx
Normal file
252
packages/ee/advanced-targeting/components/segment-settings.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { FilterIcon, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
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/actionClasses";
|
||||
import type { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import type { TBaseFilter, TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import { ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { ConfirmDeleteSegmentModal } from "@formbricks/ui/ConfirmDeleteSegmentModal";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
|
||||
import { deleteSegmentAction, updateSegmentAction } from "../lib/actions";
|
||||
import { AddFilterModal } from "./add-filter-modal";
|
||||
import { SegmentEditor } from "./segment-editor";
|
||||
|
||||
interface TSegmentSettingsTabProps {
|
||||
environmentId: string;
|
||||
setOpen: (open: boolean) => void;
|
||||
initialSegment: TSegmentWithSurveyNames;
|
||||
segments: TSegment[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
actionClasses: TActionClass[];
|
||||
}
|
||||
|
||||
export function SegmentSettings({
|
||||
environmentId,
|
||||
initialSegment,
|
||||
setOpen,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
segments,
|
||||
}: TSegmentSettingsTabProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const [addFilterModalOpen, setAddFilterModalOpen] = useState(false);
|
||||
const [segment, setSegment] = useState<TSegment>(initialSegment);
|
||||
|
||||
const [isUpdatingSegment, setIsUpdatingSegment] = useState(false);
|
||||
const [isDeletingSegment, setIsDeletingSegment] = useState(false);
|
||||
|
||||
const [isDeleteSegmentModalOpen, setIsDeleteSegmentModalOpen] = useState(false);
|
||||
|
||||
const handleResetState = () => {
|
||||
setSegment(initialSegment);
|
||||
setOpen(false);
|
||||
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleAddFilterInGroup = (filter: TBaseFilter) => {
|
||||
const updatedSegment = structuredClone(segment);
|
||||
if (updatedSegment.filters.length === 0) {
|
||||
updatedSegment.filters.push({
|
||||
...filter,
|
||||
connector: null,
|
||||
});
|
||||
} else {
|
||||
updatedSegment.filters.push(filter);
|
||||
}
|
||||
|
||||
setSegment(updatedSegment);
|
||||
};
|
||||
|
||||
const handleUpdateSegment = async () => {
|
||||
if (!segment.title) {
|
||||
toast.error("Title is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUpdatingSegment(true);
|
||||
await updateSegmentAction(segment.environmentId, segment.id, {
|
||||
title: segment.title,
|
||||
description: segment.description ?? "",
|
||||
isPrivate: segment.isPrivate,
|
||||
filters: segment.filters,
|
||||
});
|
||||
|
||||
setIsUpdatingSegment(false);
|
||||
toast.success("Segment updated successfully!");
|
||||
} catch (err: any) {
|
||||
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
|
||||
if (!parsedFilters.success) {
|
||||
toast.error("Invalid filters. Please check the filters and try again.");
|
||||
} else {
|
||||
toast.error("Something went wrong. Please try again.");
|
||||
}
|
||||
setIsUpdatingSegment(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdatingSegment(false);
|
||||
handleResetState();
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleDeleteSegment = async () => {
|
||||
try {
|
||||
setIsDeletingSegment(true);
|
||||
await deleteSegmentAction(segment.environmentId, segment.id);
|
||||
|
||||
setIsDeletingSegment(false);
|
||||
toast.success("Segment deleted successfully!");
|
||||
handleResetState();
|
||||
} catch (err: any) {
|
||||
toast.error("Something went wrong. Please try again.");
|
||||
}
|
||||
|
||||
setIsDeletingSegment(false);
|
||||
};
|
||||
|
||||
const isSaveDisabled = useMemo(() => {
|
||||
// check if title is empty
|
||||
|
||||
if (!segment.title) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// parse the filters to check if they are valid
|
||||
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
|
||||
if (!parsedFilters.success) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [segment]);
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className="rounded-lg bg-slate-50">
|
||||
<div className="flex flex-col overflow-auto rounded-lg bg-white">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<div className="flex w-1/2 flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">Title</label>
|
||||
<div className="relative flex flex-col gap-1">
|
||||
<Input
|
||||
className="w-auto"
|
||||
onChange={(e) => {
|
||||
setSegment((prev) => ({
|
||||
...prev,
|
||||
title: e.target.value,
|
||||
}));
|
||||
}}
|
||||
placeholder="Ex. Power Users"
|
||||
value={segment.title}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-1/2 flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">Description</label>
|
||||
<div className="relative flex flex-col gap-1">
|
||||
<Input
|
||||
className={cn("w-auto")}
|
||||
onChange={(e) => {
|
||||
setSegment((prev) => ({
|
||||
...prev,
|
||||
description: e.target.value,
|
||||
}));
|
||||
}}
|
||||
placeholder="Ex. Power Users"
|
||||
value={segment.description ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="my-4 text-sm font-medium text-slate-900">Targeting</label>
|
||||
<div className="filter-scrollbar flex max-h-96 w-full flex-col gap-4 overflow-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
{segment.filters.length === 0 && (
|
||||
<div className="-mb-2 flex items-center gap-1">
|
||||
<FilterIcon className="h-5 w-5 text-slate-700" />
|
||||
<h3 className="text-sm font-medium text-slate-700">Add your first filter to get started</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SegmentEditor
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
environmentId={environmentId}
|
||||
group={segment.filters}
|
||||
segment={segment}
|
||||
segments={segments}
|
||||
setSegment={setSegment}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setAddFilterModalOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
variant="secondary">
|
||||
Add Filter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AddFilterModal
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
onAddFilter={(filter) => {
|
||||
handleAddFilterInGroup(filter);
|
||||
}}
|
||||
open={addFilterModalOpen}
|
||||
segments={segments}
|
||||
setOpen={setAddFilterModalOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center justify-between pt-4">
|
||||
<Button
|
||||
EndIcon={Trash2}
|
||||
endIconClassName="p-0.5"
|
||||
loading={isDeletingSegment}
|
||||
onClick={() => {
|
||||
setIsDeleteSegmentModalOpen(true);
|
||||
}}
|
||||
type="button"
|
||||
variant="warn">
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSaveDisabled}
|
||||
loading={isUpdatingSegment}
|
||||
onClick={() => {
|
||||
handleUpdateSegment();
|
||||
}}
|
||||
type="submit"
|
||||
variant="darkCTA">
|
||||
Save Changes
|
||||
</Button>
|
||||
|
||||
{isDeleteSegmentModalOpen ? (
|
||||
<ConfirmDeleteSegmentModal
|
||||
onDelete={handleDeleteSegment}
|
||||
open={isDeleteSegmentModalOpen}
|
||||
segment={initialSegment}
|
||||
setOpen={setIsDeleteSegmentModalOpen}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
import { canUserAccessSurvey } from "@formbricks/lib/survey/auth";
|
||||
import { loadNewSegmentInSurvey } from "@formbricks/lib/survey/service";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { TSegmentCreateInput, TSegmentUpdateInput, ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import type { TSegmentCreateInput, TSegmentUpdateInput } from "@formbricks/types/segment";
|
||||
import { ZSegmentFilters } from "@formbricks/types/segment";
|
||||
|
||||
export const createSegmentAction = async ({
|
||||
description,
|
||||
@@ -1,263 +0,0 @@
|
||||
import { MoreVertical, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import {
|
||||
addFilterBelow,
|
||||
addFilterInGroup,
|
||||
createGroupFromResource,
|
||||
deleteResource,
|
||||
isResourceFilter,
|
||||
moveResource,
|
||||
toggleGroupConnector,
|
||||
} from "@formbricks/lib/segment/utils";
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import { TBaseFilter, TBaseFilters, TSegment, TSegmentConnector } from "@formbricks/types/segment";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
|
||||
import { AddFilterModal } from "./AddFilterModal";
|
||||
import { SegmentFilter } from "./SegmentFilter";
|
||||
|
||||
type TSegmentEditorProps = {
|
||||
group: TBaseFilters;
|
||||
environmentId: string;
|
||||
segment: TSegment;
|
||||
segments: TSegment[];
|
||||
actionClasses: TActionClass[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
setSegment: React.Dispatch<React.SetStateAction<TSegment>>;
|
||||
viewOnly?: boolean;
|
||||
};
|
||||
|
||||
export const SegmentEditor = ({
|
||||
group,
|
||||
environmentId,
|
||||
setSegment,
|
||||
segment,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
segments,
|
||||
viewOnly = false,
|
||||
}: TSegmentEditorProps) => {
|
||||
const [addFilterModalOpen, setAddFilterModalOpen] = useState(false);
|
||||
const [addFilterModalOpenedFromBelow, setAddFilterModalOpenedFromBelow] = useState(false);
|
||||
|
||||
const handleAddFilterBelow = (resourceId: string, filter: TBaseFilter) => {
|
||||
const localSegmentCopy = structuredClone(segment);
|
||||
|
||||
if (localSegmentCopy.filters) {
|
||||
addFilterBelow(localSegmentCopy.filters, resourceId, filter);
|
||||
}
|
||||
|
||||
setSegment(localSegmentCopy);
|
||||
};
|
||||
|
||||
const handleCreateGroup = (resourceId: string) => {
|
||||
const localSegmentCopy = structuredClone(segment);
|
||||
if (localSegmentCopy.filters) {
|
||||
createGroupFromResource(localSegmentCopy.filters, resourceId);
|
||||
}
|
||||
|
||||
setSegment(localSegmentCopy);
|
||||
};
|
||||
|
||||
const handleMoveResource = (resourceId: string, direction: "up" | "down") => {
|
||||
const localSegmentCopy = structuredClone(segment);
|
||||
if (localSegmentCopy.filters) {
|
||||
moveResource(localSegmentCopy.filters, resourceId, direction);
|
||||
}
|
||||
|
||||
setSegment(localSegmentCopy);
|
||||
};
|
||||
|
||||
const handleDeleteResource = (resourceId: string) => {
|
||||
const localSegmentCopy = structuredClone(segment);
|
||||
|
||||
if (localSegmentCopy.filters) {
|
||||
deleteResource(localSegmentCopy.filters, resourceId);
|
||||
}
|
||||
|
||||
setSegment(localSegmentCopy);
|
||||
};
|
||||
|
||||
const handleToggleGroupConnector = (groupId: string, newConnectorValue: TSegmentConnector) => {
|
||||
const localSegmentCopy = structuredClone(segment);
|
||||
if (localSegmentCopy.filters) {
|
||||
toggleGroupConnector(localSegmentCopy.filters, groupId, newConnectorValue);
|
||||
}
|
||||
|
||||
setSegment(localSegmentCopy);
|
||||
};
|
||||
|
||||
const onConnectorChange = (groupId: string, connector: TSegmentConnector) => {
|
||||
if (!connector) return;
|
||||
|
||||
if (connector === "and") {
|
||||
handleToggleGroupConnector(groupId, "or");
|
||||
} else {
|
||||
handleToggleGroupConnector(groupId, "and");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddFilterInGroup = (groupId: string, filter: TBaseFilter) => {
|
||||
const localSegmentCopy = structuredClone(segment);
|
||||
|
||||
if (localSegmentCopy.filters) {
|
||||
addFilterInGroup(localSegmentCopy.filters, groupId, filter);
|
||||
}
|
||||
setSegment(localSegmentCopy);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg">
|
||||
{group?.map((groupItem) => {
|
||||
const { connector, resource, id: groupId } = groupItem;
|
||||
|
||||
if (isResourceFilter(resource)) {
|
||||
return (
|
||||
<SegmentFilter
|
||||
key={groupId}
|
||||
connector={connector}
|
||||
resource={resource}
|
||||
environmentId={environmentId}
|
||||
segment={segment}
|
||||
segments={segments}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
setSegment={setSegment}
|
||||
handleAddFilterBelow={handleAddFilterBelow}
|
||||
onCreateGroup={(filterId: string) => handleCreateGroup(filterId)}
|
||||
onDeleteFilter={(filterId: string) => handleDeleteResource(filterId)}
|
||||
onMoveFilter={(filterId: string, direction: "up" | "down") =>
|
||||
handleMoveResource(filterId, direction)
|
||||
}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div key={groupId}>
|
||||
<div className="flex items-start gap-2">
|
||||
<div key={connector} className="w-auto">
|
||||
<span
|
||||
className={cn(
|
||||
!!connector && "cursor-pointer underline",
|
||||
"text-sm",
|
||||
viewOnly && "cursor-not-allowed"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (viewOnly) return;
|
||||
onConnectorChange(groupId, connector);
|
||||
}}>
|
||||
{!!connector ? connector : "Where"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-slate-300 bg-white p-4">
|
||||
<SegmentEditor
|
||||
group={resource}
|
||||
environmentId={environmentId}
|
||||
segment={segment}
|
||||
setSegment={setSegment}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
segments={segments}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (viewOnly) return;
|
||||
setAddFilterModalOpen(true);
|
||||
}}
|
||||
disabled={viewOnly}>
|
||||
Add filter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AddFilterModal
|
||||
open={addFilterModalOpen}
|
||||
setOpen={setAddFilterModalOpen}
|
||||
onAddFilter={(filter) => {
|
||||
if (addFilterModalOpenedFromBelow) {
|
||||
handleAddFilterBelow(groupId, filter);
|
||||
setAddFilterModalOpenedFromBelow(false);
|
||||
} else {
|
||||
handleAddFilterInGroup(groupId, filter);
|
||||
}
|
||||
}}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
segments={segments}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger disabled={viewOnly}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setAddFilterModalOpenedFromBelow(true);
|
||||
setAddFilterModalOpen(true);
|
||||
}}>
|
||||
Add filter below
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleCreateGroup(groupId);
|
||||
}}>
|
||||
Create group
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleMoveResource(groupId, "up");
|
||||
}}>
|
||||
Move up
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (viewOnly) return;
|
||||
handleMoveResource(groupId, "down");
|
||||
}}>
|
||||
Move down
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="minimal"
|
||||
className="p-0"
|
||||
disabled={viewOnly}
|
||||
onClick={() => {
|
||||
if (viewOnly) return;
|
||||
handleDeleteResource(groupId);
|
||||
}}>
|
||||
<Trash2 className={cn("h-4 w-4 cursor-pointer", viewOnly && "cursor-not-allowed")} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,248 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FilterIcon, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import { TBaseFilter, TSegment, TSegmentWithSurveyNames, ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { ConfirmDeleteSegmentModal } from "@formbricks/ui/ConfirmDeleteSegmentModal";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
|
||||
import { deleteSegmentAction, updateSegmentAction } from "../lib/actions";
|
||||
import { AddFilterModal } from "./AddFilterModal";
|
||||
import { SegmentEditor } from "./SegmentEditor";
|
||||
|
||||
type TSegmentSettingsTabProps = {
|
||||
environmentId: string;
|
||||
setOpen: (open: boolean) => void;
|
||||
initialSegment: TSegmentWithSurveyNames;
|
||||
segments: TSegment[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
actionClasses: TActionClass[];
|
||||
};
|
||||
|
||||
export const SegmentSettings = ({
|
||||
environmentId,
|
||||
initialSegment,
|
||||
setOpen,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
segments,
|
||||
}: TSegmentSettingsTabProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const [addFilterModalOpen, setAddFilterModalOpen] = useState(false);
|
||||
const [segment, setSegment] = useState<TSegment>(initialSegment);
|
||||
|
||||
const [isUpdatingSegment, setIsUpdatingSegment] = useState(false);
|
||||
const [isDeletingSegment, setIsDeletingSegment] = useState(false);
|
||||
|
||||
const [isDeleteSegmentModalOpen, setIsDeleteSegmentModalOpen] = useState(false);
|
||||
|
||||
const handleResetState = () => {
|
||||
setSegment(initialSegment);
|
||||
setOpen(false);
|
||||
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleAddFilterInGroup = (filter: TBaseFilter) => {
|
||||
const updatedSegment = structuredClone(segment);
|
||||
if (updatedSegment?.filters?.length === 0) {
|
||||
updatedSegment.filters.push({
|
||||
...filter,
|
||||
connector: null,
|
||||
});
|
||||
} else {
|
||||
updatedSegment?.filters.push(filter);
|
||||
}
|
||||
|
||||
setSegment(updatedSegment);
|
||||
};
|
||||
|
||||
const handleUpdateSegment = async () => {
|
||||
if (!segment.title) {
|
||||
toast.error("Title is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUpdatingSegment(true);
|
||||
await updateSegmentAction(segment.environmentId, segment.id, {
|
||||
title: segment.title,
|
||||
description: segment.description ?? "",
|
||||
isPrivate: segment.isPrivate,
|
||||
filters: segment.filters,
|
||||
});
|
||||
|
||||
setIsUpdatingSegment(false);
|
||||
toast.success("Segment updated successfully!");
|
||||
} catch (err: any) {
|
||||
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
|
||||
if (!parsedFilters.success) {
|
||||
toast.error("Invalid filters. Please check the filters and try again.");
|
||||
} else {
|
||||
toast.error("Something went wrong. Please try again.");
|
||||
}
|
||||
setIsUpdatingSegment(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdatingSegment(false);
|
||||
handleResetState();
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleDeleteSegment = async () => {
|
||||
try {
|
||||
setIsDeletingSegment(true);
|
||||
await deleteSegmentAction(segment.environmentId, segment.id);
|
||||
|
||||
setIsDeletingSegment(false);
|
||||
toast.success("Segment deleted successfully!");
|
||||
handleResetState();
|
||||
} catch (err: any) {
|
||||
toast.error("Something went wrong. Please try again.");
|
||||
}
|
||||
|
||||
setIsDeletingSegment(false);
|
||||
};
|
||||
|
||||
const isSaveDisabled = useMemo(() => {
|
||||
// check if title is empty
|
||||
|
||||
if (!segment.title) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// parse the filters to check if they are valid
|
||||
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
|
||||
if (!parsedFilters.success) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [segment]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<div className="rounded-lg bg-slate-50">
|
||||
<div className="flex flex-col overflow-auto rounded-lg bg-white">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<div className="flex w-1/2 flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">Title</label>
|
||||
<div className="relative flex flex-col gap-1">
|
||||
<Input
|
||||
value={segment.title}
|
||||
placeholder="Ex. Power Users"
|
||||
onChange={(e) => {
|
||||
setSegment((prev) => ({
|
||||
...prev,
|
||||
title: e.target.value,
|
||||
}));
|
||||
}}
|
||||
className="w-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-1/2 flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">Description</label>
|
||||
<div className="relative flex flex-col gap-1">
|
||||
<Input
|
||||
value={segment.description ?? ""}
|
||||
placeholder="Ex. Power Users"
|
||||
onChange={(e) => {
|
||||
setSegment((prev) => ({
|
||||
...prev,
|
||||
description: e.target.value,
|
||||
}));
|
||||
}}
|
||||
className={cn("w-auto")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="my-4 text-sm font-medium text-slate-900">Targeting</label>
|
||||
<div className="filter-scrollbar flex max-h-96 w-full flex-col gap-4 overflow-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
{segment?.filters?.length === 0 && (
|
||||
<div className="-mb-2 flex items-center gap-1">
|
||||
<FilterIcon className="h-5 w-5 text-slate-700" />
|
||||
<h3 className="text-sm font-medium text-slate-700">Add your first filter to get started</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SegmentEditor
|
||||
environmentId={environmentId}
|
||||
segment={segment}
|
||||
setSegment={setSegment}
|
||||
group={segment.filters}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
segments={segments}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button variant="secondary" size="sm" onClick={() => setAddFilterModalOpen(true)}>
|
||||
Add Filter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AddFilterModal
|
||||
onAddFilter={(filter) => {
|
||||
handleAddFilterInGroup(filter);
|
||||
}}
|
||||
open={addFilterModalOpen}
|
||||
setOpen={setAddFilterModalOpen}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
segments={segments}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center justify-between pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="warn"
|
||||
loading={isDeletingSegment}
|
||||
onClick={() => {
|
||||
setIsDeleteSegmentModalOpen(true);
|
||||
}}
|
||||
EndIcon={Trash2}
|
||||
endIconClassName="p-0.5">
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
type="submit"
|
||||
loading={isUpdatingSegment}
|
||||
onClick={() => {
|
||||
handleUpdateSegment();
|
||||
}}
|
||||
disabled={isSaveDisabled}>
|
||||
Save Changes
|
||||
</Button>
|
||||
|
||||
{isDeleteSegmentModalOpen && (
|
||||
<ConfirmDeleteSegmentModal
|
||||
onDelete={handleDeleteSegment}
|
||||
open={isDeleteSegmentModalOpen}
|
||||
segment={initialSegment}
|
||||
setOpen={setIsDeleteSegmentModalOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -3,21 +3,24 @@ import Stripe from "stripe";
|
||||
import { STRIPE_API_VERSION } from "@formbricks/lib/constants";
|
||||
import { env } from "@formbricks/lib/env";
|
||||
|
||||
import { handleCheckoutSessionCompleted } from "../handlers/checkoutSessionCompleted";
|
||||
import { handleSubscriptionUpdatedOrCreated } from "../handlers/subscriptionCreatedOrUpdated";
|
||||
import { handleSubscriptionDeleted } from "../handlers/subscriptionDeleted";
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
|
||||
const webhookSecret: string = env.STRIPE_WEBHOOK_SECRET!;
|
||||
import { handleCheckoutSessionCompleted } from "../handlers/checkout-session-completed";
|
||||
import { handleSubscriptionUpdatedOrCreated } from "../handlers/subscription-created-or-updated";
|
||||
import { handleSubscriptionDeleted } from "../handlers/subscription-deleted";
|
||||
|
||||
export const webhookHandler = async (requestBody: string, stripeSignature: string) => {
|
||||
let event: Stripe.Event;
|
||||
|
||||
if (!env.STRIPE_SECRET_KEY || !env.STRIPE_WEBHOOK_SECRET) {
|
||||
console.error("Stripe is not enabled, skipping webhook");
|
||||
return { status: 400, message: "Stripe is not enabled, skipping webhook" };
|
||||
}
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(requestBody, stripeSignature, webhookSecret);
|
||||
event = stripe.webhooks.constructEvent(requestBody, stripeSignature, env.STRIPE_WEBHOOK_SECRET);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||
if (err! instanceof Error) console.error(err);
|
||||
|
||||
@@ -10,15 +10,17 @@ import {
|
||||
} from "@formbricks/lib/organization/service";
|
||||
|
||||
import { ProductFeatureKeys, StripePriceLookupKeys, StripeProductNames } from "../lib/constants";
|
||||
import { reportUsage } from "../lib/reportUsage";
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
|
||||
// https://github.com/stripe/stripe-node#configuration
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
import { reportUsage } from "../lib/report-usage";
|
||||
|
||||
export const handleCheckoutSessionCompleted = async (event: Stripe.Event) => {
|
||||
if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set.");
|
||||
|
||||
const checkoutSession = event.data.object as Stripe.Checkout.Session;
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
|
||||
const stripeSubscriptionObject = await stripe.subscriptions.retrieve(
|
||||
checkoutSession.subscription as string
|
||||
);
|
||||
@@ -29,7 +31,7 @@ export const handleCheckoutSessionCompleted = async (event: Stripe.Event) => {
|
||||
|
||||
const organization = await getOrganization(stripeSubscriptionObject.metadata.organizationId);
|
||||
if (!organization) throw new Error("Organization not found.");
|
||||
let updatedFeatures = organization.billing.features;
|
||||
const updatedFeatures = organization.billing.features;
|
||||
|
||||
for (const item of stripeSubscriptionObject.items.data) {
|
||||
const product = await stripe.products.retrieve(item.price.product as string);
|
||||
@@ -10,17 +10,18 @@ import {
|
||||
} from "@formbricks/lib/organization/service";
|
||||
|
||||
import { ProductFeatureKeys, StripePriceLookupKeys, StripeProductNames } from "../lib/constants";
|
||||
import { reportUsage } from "../lib/reportUsage";
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
|
||||
// https://github.com/stripe/stripe-node#configuration
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
import { reportUsage } from "../lib/report-usage";
|
||||
|
||||
const isProductScheduled = async (
|
||||
scheduledSubscriptions: Stripe.SubscriptionSchedule[],
|
||||
productName: StripeProductNames
|
||||
) => {
|
||||
if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set.");
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
|
||||
for (const scheduledSub of scheduledSubscriptions) {
|
||||
if (scheduledSub.phases && scheduledSub.phases.length > 0) {
|
||||
const firstPhase = scheduledSub.phases[0];
|
||||
@@ -38,6 +39,12 @@ const isProductScheduled = async (
|
||||
};
|
||||
|
||||
export const handleSubscriptionUpdatedOrCreated = async (event: Stripe.Event) => {
|
||||
if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set.");
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
|
||||
const stripeSubscriptionObject = event.data.object as Stripe.Subscription;
|
||||
const organizationId = stripeSubscriptionObject.metadata.organizationId;
|
||||
|
||||
@@ -52,12 +59,12 @@ export const handleSubscriptionUpdatedOrCreated = async (event: Stripe.Event) =>
|
||||
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) throw new Error("Organization not found.");
|
||||
let updatedFeatures = organization.billing.features;
|
||||
const updatedFeatures = organization.billing.features;
|
||||
|
||||
let scheduledSubscriptions: Stripe.SubscriptionSchedule[] = [];
|
||||
if (stripeSubscriptionObject.cancel_at_period_end) {
|
||||
const allScheduledSubscriptions = await stripe.subscriptionSchedules.list({
|
||||
customer: organization.billing.stripeCustomerId as string,
|
||||
customer: organization.billing.stripeCustomerId!,
|
||||
});
|
||||
scheduledSubscriptions = allScheduledSubscriptions.data.filter(
|
||||
(scheduledSub) => scheduledSub.status === "not_started"
|
||||
@@ -5,14 +5,15 @@ import { env } from "@formbricks/lib/env";
|
||||
import { getOrganization, updateOrganization } from "@formbricks/lib/organization/service";
|
||||
|
||||
import { ProductFeatureKeys, StripeProductNames } from "../lib/constants";
|
||||
import { unsubscribeCoreAndAppSurveyFeatures, unsubscribeLinkSurveyProFeatures } from "../lib/downgradePlan";
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
|
||||
// https://github.com/stripe/stripe-node#configuration
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
import { unsubscribeCoreAndAppSurveyFeatures, unsubscribeLinkSurveyProFeatures } from "../lib/downgrade-plan";
|
||||
|
||||
export const handleSubscriptionDeleted = async (event: Stripe.Event) => {
|
||||
if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set.");
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
|
||||
const stripeSubscriptionObject = event.data.object as Stripe.Subscription;
|
||||
const organizationId = stripeSubscriptionObject.metadata.organizationId;
|
||||
if (!organizationId) {
|
||||
@@ -23,7 +24,7 @@ export const handleSubscriptionDeleted = async (event: Stripe.Event) => {
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) throw new Error("Organization not found.");
|
||||
|
||||
let updatedFeatures = organization.billing.features;
|
||||
const updatedFeatures = organization.billing.features;
|
||||
|
||||
for (const item of stripeSubscriptionObject.items.data) {
|
||||
const product = await stripe.products.retrieve(item.price.product as string);
|
||||
@@ -3,12 +3,13 @@ import Stripe from "stripe";
|
||||
import { STRIPE_API_VERSION } from "@formbricks/lib/constants";
|
||||
import { env } from "@formbricks/lib/env";
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
|
||||
// https://github.com/stripe/stripe-node#configuration
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
|
||||
export const createCustomerPortalSession = async (stripeCustomerId: string, returnUrl: string) => {
|
||||
if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set.");
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: stripeCustomerId,
|
||||
return_url: returnUrl,
|
||||
@@ -4,13 +4,7 @@ import { STRIPE_API_VERSION, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { env } from "@formbricks/lib/env";
|
||||
import { getOrganization } from "@formbricks/lib/organization/service";
|
||||
|
||||
import { StripePriceLookupKeys } from "./constants";
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
|
||||
const baseUrl = process.env.NODE_ENV === "production" ? WEBAPP_URL : "http://localhost:3000";
|
||||
import type { StripePriceLookupKeys } from "./constants";
|
||||
|
||||
export const getFirstOfNextMonthTimestamp = (): number => {
|
||||
const nextMonth = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1);
|
||||
@@ -22,14 +16,20 @@ export const createSubscription = async (
|
||||
environmentId: string,
|
||||
priceLookupKeys: StripePriceLookupKeys[]
|
||||
) => {
|
||||
if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set.");
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
|
||||
try {
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) throw new Error("Organization not found.");
|
||||
let isNewOrganization =
|
||||
const isNewOrganization =
|
||||
!organization.billing.stripeCustomerId ||
|
||||
!(await stripe.customers.retrieve(organization.billing.stripeCustomerId));
|
||||
|
||||
let lineItems: { price: string; quantity?: number }[] = [];
|
||||
const lineItems: { price: string; quantity?: number }[] = [];
|
||||
|
||||
const prices = (
|
||||
await stripe.prices.list({
|
||||
@@ -50,8 +50,8 @@ export const createSubscription = async (
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
line_items: lineItems,
|
||||
success_url: `${baseUrl}/billing-confirmation?environmentId=${environmentId}`,
|
||||
cancel_url: `${baseUrl}/environments/${environmentId}/settings/billing`,
|
||||
success_url: `${WEBAPP_URL}/billing-confirmation?environmentId=${environmentId}`,
|
||||
cancel_url: `${WEBAPP_URL}/environments/${environmentId}/settings/billing`,
|
||||
allow_promotion_codes: true,
|
||||
subscription_data: {
|
||||
billing_cycle_anchor: getFirstOfNextMonthTimestamp(),
|
||||
@@ -64,7 +64,7 @@ export const createSubscription = async (
|
||||
}
|
||||
|
||||
const existingSubscription = (
|
||||
(await stripe.customers.retrieve(organization.billing.stripeCustomerId as string, {
|
||||
(await stripe.customers.retrieve(organization.billing.stripeCustomerId!, {
|
||||
expand: ["subscriptions"],
|
||||
})) as any
|
||||
).subscriptions.data[0] as Stripe.Subscription;
|
||||
@@ -75,7 +75,7 @@ export const createSubscription = async (
|
||||
// this is a case where the organization cancelled an already purchased product
|
||||
if (existingSubscription.cancel_at_period_end) {
|
||||
const allScheduledSubscriptions = await stripe.subscriptionSchedules.list({
|
||||
customer: organization.billing.stripeCustomerId as string,
|
||||
customer: organization.billing.stripeCustomerId!,
|
||||
});
|
||||
const scheduledSubscriptions = allScheduledSubscriptions.data.filter(
|
||||
(scheduledSub) => scheduledSub.status === "not_started"
|
||||
@@ -95,13 +95,12 @@ export const createSubscription = async (
|
||||
|
||||
const combinedLineItems = [...lineItems, ...existingItemsInScheduledSubscription];
|
||||
|
||||
const uniqueItemsMap = combinedLineItems.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.price] = item; // This will overwrite duplicate items based on price
|
||||
return acc;
|
||||
},
|
||||
{} as { [key: string]: { price: string; quantity?: number } }
|
||||
);
|
||||
const uniqueItemsMap = combinedLineItems.reduce<
|
||||
Record<string, { price: string; quantity?: number }>
|
||||
>((acc, item) => {
|
||||
acc[item.price] = item; // This will overwrite duplicate items based on price
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const lineItemsForScheduledSubscription = Object.values(uniqueItemsMap);
|
||||
|
||||
@@ -122,7 +121,7 @@ export const createSubscription = async (
|
||||
// we create one since the current one with other products is expiring
|
||||
// so the new schedule only has the new product the organization has subscribed to
|
||||
await stripe.subscriptionSchedules.create({
|
||||
customer: organization.billing.stripeCustomerId as string,
|
||||
customer: organization.billing.stripeCustomerId!,
|
||||
start_date: getFirstOfNextMonthTimestamp(),
|
||||
end_behavior: "release",
|
||||
phases: [
|
||||
@@ -165,7 +164,7 @@ export const createSubscription = async (
|
||||
// case where organization does not have a subscription but has a stripe customer id
|
||||
// so we just attach that to a new subscription
|
||||
await stripe.subscriptions.create({
|
||||
customer: organization.billing.stripeCustomerId as string,
|
||||
customer: organization.billing.stripeCustomerId!,
|
||||
items: lineItems,
|
||||
billing_cycle_anchor: getFirstOfNextMonthTimestamp(),
|
||||
metadata: { organizationId },
|
||||
@@ -184,7 +183,7 @@ export const createSubscription = async (
|
||||
status: 500,
|
||||
data: "Something went wrong!",
|
||||
newPlan: true,
|
||||
url: `${baseUrl}/environments/${environmentId}/settings/billing`,
|
||||
url: `${WEBAPP_URL}/environments/${environmentId}/settings/billing`,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -4,22 +4,32 @@ import { STRIPE_API_VERSION, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { env } from "@formbricks/lib/env";
|
||||
import { getOrganization, updateOrganization } from "@formbricks/lib/organization/service";
|
||||
|
||||
import { StripePriceLookupKeys } from "./constants";
|
||||
import { getFirstOfNextMonthTimestamp } from "./createSubscription";
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
import type { StripePriceLookupKeys } from "./constants";
|
||||
import { getFirstOfNextMonthTimestamp } from "./create-subscription";
|
||||
|
||||
const baseUrl = process.env.NODE_ENV === "production" ? WEBAPP_URL : "http://localhost:3000";
|
||||
|
||||
const retrievePriceLookup = async (priceId: string) => (await stripe.prices.retrieve(priceId)).lookup_key;
|
||||
const retrievePriceLookup = async (priceId: string) => {
|
||||
if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set.");
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
|
||||
return (await stripe.prices.retrieve(priceId)).lookup_key;
|
||||
};
|
||||
|
||||
export const removeSubscription = async (
|
||||
organizationId: string,
|
||||
environmentId: string,
|
||||
priceLookupKeys: StripePriceLookupKeys[]
|
||||
) => {
|
||||
if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set.");
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
|
||||
try {
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) throw new Error("Organization not found.");
|
||||
@@ -30,7 +40,7 @@ export const removeSubscription = async (
|
||||
const existingCustomer = (await stripe.customers.retrieve(organization.billing.stripeCustomerId, {
|
||||
expand: ["subscriptions"],
|
||||
})) as Stripe.Customer;
|
||||
const existingSubscription = existingCustomer.subscriptions?.data[0] as Stripe.Subscription;
|
||||
const existingSubscription = existingCustomer.subscriptions?.data[0]!;
|
||||
|
||||
const allScheduledSubscriptions = await stripe.subscriptionSchedules.list({
|
||||
customer: organization.billing.stripeCustomerId,
|
||||
@@ -93,7 +103,7 @@ export const removeSubscription = async (
|
||||
|
||||
await stripe.subscriptions.update(existingSubscription.id, { cancel_at_period_end: true });
|
||||
|
||||
let updatedFeatures = organization.billing.features;
|
||||
const updatedFeatures = organization.billing.features;
|
||||
for (const priceLookupKey of priceLookupKeys) {
|
||||
updatedFeatures[priceLookupKey as keyof typeof updatedFeatures].status = "cancelled";
|
||||
}
|
||||
@@ -5,16 +5,17 @@ import { env } from "@formbricks/lib/env";
|
||||
|
||||
import { ProductFeatureKeys } from "./constants";
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
|
||||
// https://github.com/stripe/stripe-node#configuration
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
|
||||
export const reportUsage = async (
|
||||
items: Stripe.SubscriptionItem[],
|
||||
lookupKey: ProductFeatureKeys,
|
||||
quantity: number
|
||||
) => {
|
||||
if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set.");
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
|
||||
const subscriptionItem = items.find(
|
||||
(subItem) => subItem.price.lookup_key === ProductFeatureKeys[lookupKey]
|
||||
);
|
||||
@@ -25,7 +26,7 @@ export const reportUsage = async (
|
||||
|
||||
await stripe.subscriptionItems.createUsageRecord(subscriptionItem.id, {
|
||||
action: "set",
|
||||
quantity: quantity,
|
||||
quantity,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
};
|
||||
@@ -36,6 +37,12 @@ export const reportUsageToStripe = async (
|
||||
lookupKey: ProductFeatureKeys,
|
||||
timestamp: number
|
||||
) => {
|
||||
if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set.");
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
|
||||
try {
|
||||
const subscription = await stripe.subscriptions.list({
|
||||
customer: stripeCustomerId,
|
||||
@@ -53,11 +60,11 @@ export const reportUsageToStripe = async (
|
||||
const usageRecord = await stripe.subscriptionItems.createUsageRecord(subId, {
|
||||
action: "set",
|
||||
quantity: usage,
|
||||
timestamp: timestamp,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
return { status: 200, data: usageRecord.quantity };
|
||||
} catch (error) {
|
||||
return { status: 500, data: "Something went wrong: " + error };
|
||||
return { status: 500, data: `Something went wrong: ${error}` };
|
||||
}
|
||||
};
|
||||
@@ -2,12 +2,11 @@ import "server-only";
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache, revalidateTag } from "@formbricks/lib/cache";
|
||||
import { E2E_TESTING, ENTERPRISE_LICENSE_KEY, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { hashString } from "@formbricks/lib/hashString";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
|
||||
import { prisma } from "../../database/src";
|
||||
import type { TOrganization } from "@formbricks/types/organizations";
|
||||
|
||||
const hashedKey = ENTERPRISE_LICENSE_KEY ? hashString(ENTERPRISE_LICENSE_KEY) : undefined;
|
||||
const PREVIOUS_RESULTS_CACHE_TAG_KEY = `getPreviousResult-${hashedKey}` as const;
|
||||
@@ -86,7 +85,7 @@ export const getIsEnterpriseEdition = async (): Promise<boolean> => {
|
||||
|
||||
const response = await axios.post("https://ee.formbricks.com/api/licenses/check", {
|
||||
licenseKey: process.env.ENTERPRISE_LICENSE_KEY,
|
||||
usage: { responseCount: responseCount },
|
||||
usage: { responseCount },
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
@@ -116,45 +115,44 @@ export const getIsEnterpriseEdition = async (): Promise<boolean> => {
|
||||
if (isValid !== null) {
|
||||
await setPreviousResult({ active: isValid, lastChecked: new Date() });
|
||||
return isValid;
|
||||
} else {
|
||||
// if result is undefined -> error
|
||||
// if the last check was less than 72 hours, return the previous value:
|
||||
if (new Date().getTime() - previousResult.lastChecked.getTime() <= 3 * 24 * 60 * 60 * 1000) {
|
||||
return previousResult.active !== null ? previousResult.active : false;
|
||||
}
|
||||
|
||||
// if the last check was more than 72 hours, return false and log the error
|
||||
console.error("Error while checking license: The license check failed");
|
||||
return false;
|
||||
}
|
||||
// if result is undefined -> error
|
||||
// if the last check was less than 72 hours, return the previous value:
|
||||
if (new Date().getTime() - previousResult.lastChecked.getTime() <= 3 * 24 * 60 * 60 * 1000) {
|
||||
return previousResult.active !== null ? previousResult.active : false;
|
||||
}
|
||||
|
||||
// if the last check was more than 72 hours, return false and log the error
|
||||
console.error("Error while checking license: The license check failed");
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getRemoveInAppBrandingPermission = (organization: TOrganization): boolean => {
|
||||
if (IS_FORMBRICKS_CLOUD) return organization.billing.features.inAppSurvey.status !== "inactive";
|
||||
else if (!IS_FORMBRICKS_CLOUD) return true;
|
||||
else return false;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getRemoveLinkBrandingPermission = (organization: TOrganization): boolean => {
|
||||
if (IS_FORMBRICKS_CLOUD) return organization.billing.features.linkSurvey.status !== "inactive";
|
||||
else if (!IS_FORMBRICKS_CLOUD) return true;
|
||||
else return false;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getRoleManagementPermission = async (organization: TOrganization): Promise<boolean> => {
|
||||
if (IS_FORMBRICKS_CLOUD) return organization.billing.features.inAppSurvey.status !== "inactive";
|
||||
else if (!IS_FORMBRICKS_CLOUD) return await getIsEnterpriseEdition();
|
||||
else return false;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getAdvancedTargetingPermission = async (organization: TOrganization): Promise<boolean> => {
|
||||
if (IS_FORMBRICKS_CLOUD) return organization.billing.features.userTargeting.status !== "inactive";
|
||||
else if (!IS_FORMBRICKS_CLOUD) return await getIsEnterpriseEdition();
|
||||
else return false;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getMultiLanguagePermission = async (organization: TOrganization): Promise<boolean> => {
|
||||
if (IS_FORMBRICKS_CLOUD) return organization.billing.features.inAppSurvey.status !== "inactive";
|
||||
else if (!IS_FORMBRICKS_CLOUD) return await getIsEnterpriseEdition();
|
||||
else return false;
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { TLanguage, TProduct } from "@formbricks/types/product";
|
||||
import type { TLanguage, TProduct } from "@formbricks/types/product";
|
||||
import { DefaultTag } from "@formbricks/ui/DefaultTag";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
|
||||
|
||||
import { getLanguageLabel } from "../lib/isoLanguages";
|
||||
import { ConfirmationModalProps } from "./MultiLanguageCard";
|
||||
import { getLanguageLabel } from "../lib/iso-languages";
|
||||
import type { ConfirmationModalProps } from "./multi-language-card";
|
||||
|
||||
interface DefaultLanguageSelectProps {
|
||||
defaultLanguage?: TLanguage;
|
||||
@@ -12,39 +12,41 @@ interface DefaultLanguageSelectProps {
|
||||
setConfirmationModalInfo: (confirmationModal: ConfirmationModalProps) => void;
|
||||
}
|
||||
|
||||
export const DefaultLanguageSelect = ({
|
||||
export function DefaultLanguageSelect({
|
||||
defaultLanguage,
|
||||
handleDefaultLanguageChange,
|
||||
product,
|
||||
setConfirmationModalInfo,
|
||||
}: DefaultLanguageSelectProps) => {
|
||||
}: DefaultLanguageSelectProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm">1. Choose the default language for this survey:</p>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-48 ">
|
||||
<Select
|
||||
value={`${defaultLanguage?.code}`}
|
||||
defaultValue={`${defaultLanguage?.code}`}
|
||||
disabled={defaultLanguage ? true : false}
|
||||
disabled={Boolean(defaultLanguage)}
|
||||
onValueChange={(languageCode) => {
|
||||
setConfirmationModalInfo({
|
||||
open: true,
|
||||
title: `Set ${getLanguageLabel(languageCode)} as default language`,
|
||||
text: `Once set, the default language for this survey can only be changed by disabling the multi-language option and deleting all translations.`,
|
||||
buttonText: `Set ${getLanguageLabel(languageCode)} as default language`,
|
||||
onConfirm: () => handleDefaultLanguageChange(languageCode),
|
||||
onConfirm: () => {
|
||||
handleDefaultLanguageChange(languageCode);
|
||||
},
|
||||
buttonVariant: "darkCTA",
|
||||
});
|
||||
}}>
|
||||
}}
|
||||
value={`${defaultLanguage?.code}`}>
|
||||
<SelectTrigger className="xs:w-[180px] xs:text-base w-full px-4 text-xs text-slate-800 dark:border-slate-400 dark:bg-slate-700 dark:text-slate-300">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{product.languages.map((language) => (
|
||||
<SelectItem
|
||||
key={language.id}
|
||||
className="xs:text-base px-0.5 py-1 text-xs text-slate-800 dark:bg-slate-700 dark:text-slate-300 dark:ring-slate-700"
|
||||
key={language.id}
|
||||
value={language.code}>
|
||||
{`${getLanguageLabel(language.code)} (${language.code})`}
|
||||
</SelectItem>
|
||||
@@ -56,4 +58,4 @@ export const DefaultLanguageSelect = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { InfoIcon, PlusIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
import { TLanguage, TProduct } from "@formbricks/types/product";
|
||||
import type { TLanguage, TProduct } from "@formbricks/types/product";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { ConfirmationModal } from "@formbricks/ui/ConfirmationModal";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
getSurveysUsingGivenLanguageAction,
|
||||
updateLanguageAction,
|
||||
} from "../lib/actions";
|
||||
import { iso639Languages } from "../lib/isoLanguages";
|
||||
import { LanguageRow } from "./LanguageRow";
|
||||
import { iso639Languages } from "../lib/iso-languages";
|
||||
import { LanguageRow } from "./language-row";
|
||||
|
||||
interface EditLanguageProps {
|
||||
product: TProduct;
|
||||
@@ -55,7 +55,7 @@ const validateLanguages = (languages: TLanguage[]) => {
|
||||
}
|
||||
|
||||
// Check if the chosen alias matches an ISO identifier of a language that hasn’t been added
|
||||
for (let alias of languageAliases) {
|
||||
for (const alias of languageAliases) {
|
||||
if (iso639Languages.some((language) => language.alpha2 === alias && !languageCodes.includes(alias))) {
|
||||
toast.error(
|
||||
"There is a conflict between the selected alias and another language that has this identifier. Please add the language with this identifier to your product instead to avoid inconsistencies.",
|
||||
@@ -68,7 +68,7 @@ const validateLanguages = (languages: TLanguage[]) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
export const EditLanguage = ({ product, environmentId }: EditLanguageProps) => {
|
||||
export function EditLanguage({ product, environmentId }: EditLanguageProps) {
|
||||
const [languages, setLanguages] = useState<TLanguage[]>(product.languages);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [confirmationModal, setConfirmationModal] = useState({
|
||||
@@ -146,7 +146,7 @@ export const EditLanguage = ({ product, environmentId }: EditLanguageProps) => {
|
||||
|
||||
const AddLanguageButton: React.FC<{ onClick: () => void }> = ({ onClick }) =>
|
||||
isEditing && languages.length === product.languages.length ? (
|
||||
<Button variant="secondary" onClick={onClick} size="sm">
|
||||
<Button onClick={onClick} size="sm" variant="secondary">
|
||||
<PlusIcon /> Add Language
|
||||
</Button>
|
||||
) : null;
|
||||
@@ -159,16 +159,16 @@ export const EditLanguage = ({ product, environmentId }: EditLanguageProps) => {
|
||||
<LanguageLabels />
|
||||
{languages.map((language, index) => (
|
||||
<LanguageRow
|
||||
index={index}
|
||||
isEditing={isEditing}
|
||||
key={language.id}
|
||||
language={language}
|
||||
isEditing={isEditing}
|
||||
index={index}
|
||||
onDelete={() => handleDeleteLanguage(language.id)}
|
||||
onLanguageChange={(newLanguage: TLanguage) => {
|
||||
const updatedLanguages = [...languages];
|
||||
updatedLanguages[index] = newLanguage;
|
||||
setLanguages(updatedLanguages);
|
||||
}}
|
||||
onDelete={() => handleDeleteLanguage(language.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -179,24 +179,28 @@ export const EditLanguage = ({ product, environmentId }: EditLanguageProps) => {
|
||||
</div>
|
||||
<EditSaveButtons
|
||||
isEditing={isEditing}
|
||||
onSave={handleSaveChanges}
|
||||
onCancel={handleCancelChanges}
|
||||
onEdit={() => setIsEditing(true)}
|
||||
onEdit={() => {
|
||||
setIsEditing(true);
|
||||
}}
|
||||
onSave={handleSaveChanges}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
title="Remove Language"
|
||||
buttonText={"Remove Language"}
|
||||
open={confirmationModal.isOpen}
|
||||
setOpen={() => setConfirmationModal((prev) => ({ ...prev, isOpen: !prev.isOpen }))}
|
||||
text={confirmationModal.text}
|
||||
onConfirm={() => performLanguageDeletion(confirmationModal.languageId)}
|
||||
buttonText="Remove Language"
|
||||
isButtonDisabled={confirmationModal.isButtonDisabled}
|
||||
onConfirm={() => performLanguageDeletion(confirmationModal.languageId)}
|
||||
open={confirmationModal.isOpen}
|
||||
setOpen={() => {
|
||||
setConfirmationModal((prev) => ({ ...prev, isOpen: !prev.isOpen }));
|
||||
}}
|
||||
text={confirmationModal.text}
|
||||
title="Remove Language"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const AliasTooltip = () => {
|
||||
function AliasTooltip() {
|
||||
return (
|
||||
<TooltipProvider delayDuration={80}>
|
||||
<Tooltip>
|
||||
@@ -211,17 +215,19 @@ const AliasTooltip = () => {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const LanguageLabels = () => (
|
||||
<div className="mb-2 grid w-full grid-cols-4 gap-4">
|
||||
<Label htmlFor="languagesId">Language</Label>
|
||||
<Label htmlFor="languagesId">Identifier (ISO)</Label>
|
||||
<Label htmlFor="Alias" className="flex items-center space-x-2">
|
||||
<span>Alias</span> <AliasTooltip />
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
function LanguageLabels() {
|
||||
return (
|
||||
<div className="mb-2 grid w-full grid-cols-4 gap-4">
|
||||
<Label htmlFor="languagesId">Language</Label>
|
||||
<Label htmlFor="languagesId">Identifier (ISO)</Label>
|
||||
<Label className="flex items-center space-x-2" htmlFor="Alias">
|
||||
<span>Alias</span> <AliasTooltip />
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const EditSaveButtons: React.FC<{
|
||||
isEditing: boolean;
|
||||
@@ -231,15 +237,15 @@ const EditSaveButtons: React.FC<{
|
||||
}> = ({ isEditing, onEdit, onSave, onCancel }) =>
|
||||
isEditing ? (
|
||||
<div className="flex gap-4">
|
||||
<Button variant="darkCTA" size="sm" onClick={onSave}>
|
||||
<Button onClick={onSave} size="sm" variant="darkCTA">
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button variant="minimal" size="sm" onClick={onCancel}>
|
||||
<Button onClick={onCancel} size="sm" variant="minimal">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="darkCTA" size="sm" onClick={onEdit} className="w-fit">
|
||||
<Button className="w-fit" onClick={onEdit} size="sm" variant="darkCTA">
|
||||
Edit Languages
|
||||
</Button>
|
||||
);
|
||||
@@ -2,9 +2,9 @@ import { ChevronDown } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { useClickOutside } from "@formbricks/lib/utils/hooks/useClickOutside";
|
||||
import { TSurveyLanguage } from "@formbricks/types/surveys";
|
||||
import type { TSurveyLanguage } from "@formbricks/types/surveys";
|
||||
|
||||
import { getLanguageLabel } from "../lib/isoLanguages";
|
||||
import { getLanguageLabel } from "../lib/iso-languages";
|
||||
|
||||
interface LanguageIndicatorProps {
|
||||
selectedLanguageCode: string;
|
||||
@@ -12,14 +12,16 @@ interface LanguageIndicatorProps {
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
setFirstRender?: (firstRender: boolean) => void;
|
||||
}
|
||||
export const LanguageIndicator = ({
|
||||
export function LanguageIndicator({
|
||||
surveyLanguages,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode,
|
||||
setFirstRender,
|
||||
}: LanguageIndicatorProps) => {
|
||||
}: LanguageIndicatorProps) {
|
||||
const [showLanguageDropdown, setShowLanguageDropdown] = useState(false);
|
||||
const toggleDropdown = () => setShowLanguageDropdown((prev) => !prev);
|
||||
const toggleDropdown = () => {
|
||||
setShowLanguageDropdown((prev) => !prev);
|
||||
};
|
||||
const languageDropdownRef = useRef(null);
|
||||
|
||||
const changeLanguage = (language: TSurveyLanguage) => {
|
||||
@@ -33,25 +35,27 @@ export const LanguageIndicator = ({
|
||||
|
||||
const languageToBeDisplayed = surveyLanguages.find((language) => {
|
||||
return selectedLanguageCode === "default"
|
||||
? language.default === true
|
||||
? language.default
|
||||
: language.language.code === selectedLanguageCode;
|
||||
});
|
||||
|
||||
useClickOutside(languageDropdownRef, () => setShowLanguageDropdown(false));
|
||||
useClickOutside(languageDropdownRef, () => {
|
||||
setShowLanguageDropdown(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="absolute right-2 top-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={showLanguageDropdown}
|
||||
aria-haspopup="true"
|
||||
className="flex items-center justify-center rounded-md bg-slate-900 p-1 px-2 text-xs text-white hover:bg-slate-700"
|
||||
onClick={toggleDropdown}
|
||||
tabIndex={-1}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={showLanguageDropdown}>
|
||||
{languageToBeDisplayed ? getLanguageLabel(languageToBeDisplayed?.language.code) : ""}
|
||||
type="button">
|
||||
{languageToBeDisplayed ? getLanguageLabel(languageToBeDisplayed.language.code) : ""}
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
</button>
|
||||
{showLanguageDropdown && (
|
||||
{showLanguageDropdown ? (
|
||||
<div
|
||||
className="absolute right-0 z-30 mt-1 space-y-2 rounded-md bg-slate-900 p-1 text-xs text-white "
|
||||
ref={languageDropdownRef}>
|
||||
@@ -59,16 +63,18 @@ export const LanguageIndicator = ({
|
||||
(language) =>
|
||||
language.language.code !== languageToBeDisplayed?.language.code && (
|
||||
<button
|
||||
key={language.language.id}
|
||||
type="button"
|
||||
className="block w-full rounded-sm p-1 text-left hover:bg-slate-700"
|
||||
onClick={() => changeLanguage(language)}>
|
||||
key={language.language.id}
|
||||
onClick={() => {
|
||||
changeLanguage(language);
|
||||
}}
|
||||
type="button">
|
||||
{getLanguageLabel(language.language.code)}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { TLanguage } from "@formbricks/types/product";
|
||||
import type { TLanguage } from "@formbricks/types/product";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
|
||||
import { LanguageSelect } from "./LanguageSelect";
|
||||
import { LanguageSelect } from "./language-select";
|
||||
|
||||
interface LanguageRowProps {
|
||||
language: TLanguage;
|
||||
@@ -12,26 +12,28 @@ interface LanguageRowProps {
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export const LanguageRow = ({ language, isEditing, onLanguageChange, onDelete }: LanguageRowProps) => {
|
||||
export function LanguageRow({ language, isEditing, onLanguageChange, onDelete }: LanguageRowProps) {
|
||||
return (
|
||||
<div className="my-3 grid grid-cols-4 gap-4">
|
||||
<LanguageSelect
|
||||
disabled={language.id !== "new"}
|
||||
language={language}
|
||||
onLanguageChange={onLanguageChange}
|
||||
disabled={language.id !== "new"}
|
||||
/>
|
||||
<Input disabled value={language.code} />
|
||||
<Input
|
||||
disabled={!isEditing}
|
||||
value={language.alias || ""}
|
||||
onChange={(e) => {
|
||||
onLanguageChange({ ...language, alias: e.target.value });
|
||||
}}
|
||||
placeholder="e.g. en_us"
|
||||
onChange={(e) => onLanguageChange({ ...language, alias: e.target.value })}
|
||||
value={language.alias || ""}
|
||||
/>
|
||||
{language.id !== "new" && isEditing && (
|
||||
<Button variant="warn" onClick={onDelete} className="w-fit" size="sm">
|
||||
{language.id !== "new" && isEditing ? (
|
||||
<Button className="w-fit" onClick={onDelete} size="sm" variant="warn">
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -2,11 +2,12 @@ import { ChevronDown } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useClickOutside } from "@formbricks/lib/utils/hooks/useClickOutside";
|
||||
import { TLanguage } from "@formbricks/types/product";
|
||||
import type { TLanguage } from "@formbricks/types/product";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
|
||||
import { TIso639Language, iso639Languages } from "../lib/isoLanguages";
|
||||
import type { TIso639Language } from "../lib/iso-languages";
|
||||
import { iso639Languages } from "../lib/iso-languages";
|
||||
|
||||
interface LanguageSelectProps {
|
||||
language: TLanguage;
|
||||
@@ -14,7 +15,7 @@ interface LanguageSelectProps {
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export const LanguageSelect = ({ language, onLanguageChange, disabled }: LanguageSelectProps) => {
|
||||
export function LanguageSelect({ language, onLanguageChange, disabled }: LanguageSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedOption, setSelectedOption] = useState(
|
||||
@@ -29,11 +30,13 @@ export const LanguageSelect = ({ language, onLanguageChange, disabled }: Languag
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
useClickOutside(languageSelectRef, () => setIsOpen(false));
|
||||
useClickOutside(languageSelectRef, () => {
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
const handleOptionSelect = (option: TIso639Language) => {
|
||||
setSelectedOption(option);
|
||||
onLanguageChange({ ...language, code: option?.alpha2 || "" });
|
||||
onLanguageChange({ ...language, code: option.alpha2 || "" });
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
@@ -49,29 +52,33 @@ export const LanguageSelect = ({ language, onLanguageChange, disabled }: Languag
|
||||
return (
|
||||
<div className="group relative h-full" ref={languageSelectRef}>
|
||||
<Button
|
||||
className="flex h-full w-full justify-between border border-slate-200 px-3 py-2"
|
||||
disabled={disabled}
|
||||
variant="minimal"
|
||||
onClick={toggleDropdown}
|
||||
className="flex h-full w-full justify-between border border-slate-200 px-3 py-2">
|
||||
variant="minimal">
|
||||
<span className="mr-2">{selectedOption?.english ?? "Select"}</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<div
|
||||
className={`absolute right-0 z-30 mt-2 space-y-1 rounded-md bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 ${isOpen ? "" : "hidden"}`}>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search items"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
autoComplete="off"
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
}}
|
||||
placeholder="Search items"
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
/>
|
||||
<div className="max-h-96 overflow-auto">
|
||||
{filteredItems.map((item, index) => (
|
||||
<div
|
||||
className="block cursor-pointer rounded-md px-4 py-2 text-gray-700 hover:bg-gray-100 active:bg-blue-100"
|
||||
key={index}
|
||||
onClick={() => handleOptionSelect(item)}
|
||||
className="block cursor-pointer rounded-md px-4 py-2 text-gray-700 hover:bg-gray-100 active:bg-blue-100">
|
||||
onClick={() => {
|
||||
handleOptionSelect(item);
|
||||
}}>
|
||||
{item.english}
|
||||
</div>
|
||||
))}
|
||||
@@ -79,4 +86,4 @@ export const LanguageSelect = ({ language, onLanguageChange, disabled }: Languag
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { TSurveyLanguage } from "@formbricks/types/surveys";
|
||||
import type { TSurveyLanguage } from "@formbricks/types/surveys";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -10,22 +10,22 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
|
||||
import { getLanguageLabel } from "../lib/isoLanguages";
|
||||
import { getLanguageLabel } from "../lib/iso-languages";
|
||||
|
||||
interface LanguageSwitchProps {
|
||||
surveyLanguages: TSurveyLanguage[];
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
}
|
||||
export const LanguageSwitch = ({
|
||||
export function LanguageSwitch({
|
||||
surveyLanguages,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode,
|
||||
}: LanguageSwitchProps) => {
|
||||
}: LanguageSwitchProps) {
|
||||
if (selectedLanguageCode === "default") {
|
||||
selectedLanguageCode =
|
||||
surveyLanguages.find((surveyLanguage) => {
|
||||
return surveyLanguage.default === true;
|
||||
return surveyLanguage.default;
|
||||
})?.language.code ?? "default";
|
||||
}
|
||||
return (
|
||||
@@ -45,14 +45,15 @@ export const LanguageSwitch = ({
|
||||
{surveyLanguages.length > 0 ? (
|
||||
surveyLanguages.map((surveyLanguage) => (
|
||||
<DropdownMenuItem
|
||||
key={surveyLanguage.language.id}
|
||||
className="m-0 p-0"
|
||||
key={surveyLanguage.language.id}
|
||||
onClick={() => {
|
||||
setSelectedLanguageCode(surveyLanguage.language.code);
|
||||
}}>
|
||||
<div className="flex h-full w-full items-center space-x-2 px-2 py-1 hover:bg-slate-700">
|
||||
<span
|
||||
className={`h-4 w-4 rounded-full border ${surveyLanguage.language.code === selectedLanguageCode ? "bg-brand-dark outline-brand-dark border-black outline" : "border-white"}`}></span>
|
||||
className={`h-4 w-4 rounded-full border ${surveyLanguage.language.code === selectedLanguageCode ? "bg-brand-dark outline-brand-dark border-black outline" : "border-white"}`}
|
||||
/>
|
||||
<p className="font-normal text-white">{getLanguageLabel(surveyLanguage.language.code)}</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
@@ -64,4 +65,4 @@ export const LanguageSwitch = ({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { TLanguage } from "@formbricks/types/product";
|
||||
import type { TLanguage } from "@formbricks/types/product";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { Switch } from "@formbricks/ui/Switch";
|
||||
|
||||
import { getLanguageLabel } from "../lib/isoLanguages";
|
||||
import { getLanguageLabel } from "../lib/iso-languages";
|
||||
|
||||
interface LanguageToggleProps {
|
||||
language: TLanguage;
|
||||
@@ -11,27 +11,27 @@ interface LanguageToggleProps {
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
export const LanguageToggle = ({ language, isChecked, onToggle, onEdit }: LanguageToggleProps) => {
|
||||
export function LanguageToggle({ language, isChecked, onToggle, onEdit }: LanguageToggleProps) {
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Switch
|
||||
id={`${language.code}-toggle`}
|
||||
checked={isChecked}
|
||||
id={`${language.code}-toggle`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`${language.code}-toggle`} className="font-medium text-slate-800">
|
||||
<Label className="font-medium text-slate-800" htmlFor={`${language.code}-toggle`}>
|
||||
{getLanguageLabel(language.code)}
|
||||
</Label>
|
||||
{isChecked && (
|
||||
{isChecked ? (
|
||||
<p className="cursor-pointer text-xs text-slate-600 underline" onClick={onEdit}>
|
||||
Edit {getLanguageLabel(language.code)} translations
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -5,10 +5,10 @@ import { useMemo } from "react";
|
||||
import { extractLanguageCodes, isLabelValidForAllLanguages } from "@formbricks/lib/i18n/utils";
|
||||
import { md } from "@formbricks/lib/markdownIt";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TI18nString, TSurvey } from "@formbricks/types/surveys";
|
||||
import type { TI18nString, TSurvey } from "@formbricks/types/surveys";
|
||||
import { Editor } from "@formbricks/ui/Editor";
|
||||
|
||||
import { LanguageIndicator } from "./LanguageIndicator";
|
||||
import { LanguageIndicator } from "./language-indicator";
|
||||
|
||||
interface LocalizedEditorProps {
|
||||
id: string;
|
||||
@@ -31,11 +31,11 @@ const checkIfValueIsIncomplete = (
|
||||
) => {
|
||||
const labelIds = ["subheader"];
|
||||
if (value === undefined) return false;
|
||||
const isDefaultIncomplete = labelIds.includes(id) ? value["default"]?.trim() !== "" : false;
|
||||
const isDefaultIncomplete = labelIds.includes(id) ? value.default.trim() !== "" : false;
|
||||
return isInvalid && !isLabelValidForAllLanguages(value, surveyLanguageCodes) && isDefaultIncomplete;
|
||||
};
|
||||
|
||||
export const LocalizedEditor = ({
|
||||
export function LocalizedEditor({
|
||||
id,
|
||||
value,
|
||||
localSurvey,
|
||||
@@ -46,24 +46,28 @@ export const LocalizedEditor = ({
|
||||
questionIdx,
|
||||
firstRender,
|
||||
setFirstRender,
|
||||
}: LocalizedEditorProps) => {
|
||||
}: LocalizedEditorProps) {
|
||||
const surveyLanguageCodes = useMemo(
|
||||
() => extractLanguageCodes(localSurvey.languages),
|
||||
[localSurvey.languages]
|
||||
);
|
||||
const isInComplete = useMemo(
|
||||
() => checkIfValueIsIncomplete(id, isInvalid, surveyLanguageCodes, value),
|
||||
[id, isInvalid, surveyLanguageCodes, value, selectedLanguageCode]
|
||||
[id, isInvalid, surveyLanguageCodes, value]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Editor
|
||||
key={`${questionIdx}-${selectedLanguageCode}`}
|
||||
disableLists
|
||||
excludedToolbarItems={["blockType"]}
|
||||
firstRender={firstRender}
|
||||
getText={() => md.render(value ? value[selectedLanguageCode] ?? "" : "")}
|
||||
key={`${questionIdx}-${selectedLanguageCode}`}
|
||||
setFirstRender={setFirstRender}
|
||||
setText={(v: string) => {
|
||||
if (!value) return;
|
||||
let translatedHtml = {
|
||||
const translatedHtml = {
|
||||
...value,
|
||||
[selectedLanguageCode]: v,
|
||||
};
|
||||
@@ -74,36 +78,35 @@ export const LocalizedEditor = ({
|
||||
}
|
||||
updateQuestion(questionIdx, { html: translatedHtml });
|
||||
}}
|
||||
excludedToolbarItems={["blockType"]}
|
||||
disableLists
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
{localSurvey.languages?.length > 1 && (
|
||||
{localSurvey.languages.length > 1 && (
|
||||
<div>
|
||||
<LanguageIndicator
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
surveyLanguages={localSurvey.languages}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
setFirstRender={setFirstRender}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
surveyLanguages={localSurvey.languages}
|
||||
/>
|
||||
|
||||
{value && selectedLanguageCode !== "default" && value["default"] && (
|
||||
{value && selectedLanguageCode !== "default" && value.default ? (
|
||||
<div className="mt-1 flex text-xs text-gray-500">
|
||||
<strong>Translate:</strong>
|
||||
<label
|
||||
className="fb-htmlbody ml-1" // styles are in global.css
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(
|
||||
recallToHeadline(value, localSurvey, false, "default", [])["default"] ?? ""
|
||||
recallToHeadline(value, localSurvey, false, "default", []).default ?? ""
|
||||
),
|
||||
}}></label>
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isInComplete && <div className="mt-1 text-xs text-red-400">Contains Incomplete translations</div>}
|
||||
{isInComplete ? (
|
||||
<div className="mt-1 text-xs text-red-400">Contains Incomplete translations</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -3,21 +3,23 @@
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { ArrowUpRight, Languages } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { FC, useState } from "react";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { extractLanguageCodes, translateSurvey } from "@formbricks/lib/i18n/utils";
|
||||
import { TLanguage, TProduct } from "@formbricks/types/product";
|
||||
import { TSurvey, TSurveyLanguage, ZSurvey } from "@formbricks/types/surveys";
|
||||
import type { TLanguage, TProduct } from "@formbricks/types/product";
|
||||
import type { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys";
|
||||
import { ZSurvey } from "@formbricks/types/surveys";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { ConfirmationModal } from "@formbricks/ui/ConfirmationModal";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { Switch } from "@formbricks/ui/Switch";
|
||||
import { UpgradePlanNotice } from "@formbricks/ui/UpgradePlanNotice";
|
||||
|
||||
import { DefaultLanguageSelect } from "./DefaultLanguageSelect";
|
||||
import { SecondaryLanguageSelect } from "./SecondaryLanguageSelect";
|
||||
import { DefaultLanguageSelect } from "./default-language-select";
|
||||
import { SecondaryLanguageSelect } from "./secondary-language-select";
|
||||
|
||||
interface MultiLanguageCardProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -50,8 +52,8 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
|
||||
setSelectedLanguageCode,
|
||||
}) => {
|
||||
const environmentId = localSurvey.environmentId;
|
||||
const open = activeQuestionId == "multiLanguage";
|
||||
const [isMultiLanguageActivated, setIsMultiLanguageActivated] = useState(localSurvey.languages?.length > 1);
|
||||
const open = activeQuestionId === "multiLanguage";
|
||||
const [isMultiLanguageActivated, setIsMultiLanguageActivated] = useState(localSurvey.languages.length > 1);
|
||||
const [confirmationModalInfo, setConfirmationModalInfo] = useState<ConfirmationModalProps>({
|
||||
title: "",
|
||||
open: false,
|
||||
@@ -61,8 +63,8 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
|
||||
});
|
||||
|
||||
const [defaultLanguage, setDefaultLanguage] = useState(
|
||||
localSurvey.languages?.find((language) => {
|
||||
return language.default === true;
|
||||
localSurvey.languages.find((language) => {
|
||||
return language.default;
|
||||
})?.language
|
||||
);
|
||||
|
||||
@@ -121,13 +123,12 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
|
||||
|
||||
// Update all languages and check if the new default language already exists
|
||||
const newLanguages =
|
||||
localSurvey.languages?.map((lang) => {
|
||||
localSurvey.languages.map((lang) => {
|
||||
if (lang.language.code === language.code) {
|
||||
languageExists = true;
|
||||
return { ...lang, default: true };
|
||||
} else {
|
||||
return { ...lang, default: false };
|
||||
}
|
||||
return { ...lang, default: false };
|
||||
}) ?? [];
|
||||
|
||||
if (!languageExists) {
|
||||
@@ -147,7 +148,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
|
||||
|
||||
const handleActivationSwitchLogic = () => {
|
||||
if (isMultiLanguageActivated) {
|
||||
if (localSurvey.languages?.length > 0) {
|
||||
if (localSurvey.languages.length > 0) {
|
||||
setConfirmationModalInfo({
|
||||
open: true,
|
||||
title: "Remove translations",
|
||||
@@ -185,9 +186,9 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
className="flex-1 rounded-r-lg border border-slate-200 transition-all duration-300 ease-in-out"
|
||||
onOpenChange={setOpen}
|
||||
className="flex-1 rounded-r-lg border border-slate-200 transition-all duration-300 ease-in-out">
|
||||
open={open}>
|
||||
<Collapsible.CollapsibleTrigger
|
||||
asChild
|
||||
className="flex cursor-pointer justify-between p-4 hover:bg-slate-50">
|
||||
@@ -202,12 +203,12 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
|
||||
<Label htmlFor="multi-lang-toggle">{isMultiLanguageActivated ? "On" : "Off"}</Label>
|
||||
|
||||
<Switch
|
||||
id="multi-lang-toggle"
|
||||
checked={isMultiLanguageActivated}
|
||||
disabled={!isMultiLanguageAllowed || product.languages.length === 0}
|
||||
id="multi-lang-toggle"
|
||||
onClick={() => {
|
||||
handleActivationSwitchLogic();
|
||||
}}
|
||||
disabled={!isMultiLanguageAllowed || product.languages.length === 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,14 +218,14 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
|
||||
{!isMultiLanguageAllowed && !isFormbricksCloud && !isMultiLanguageActivated ? (
|
||||
<UpgradePlanNotice
|
||||
message="To enable multi-language surveys, you need an active"
|
||||
url={`/environments/${environmentId}/settings/enterprise`}
|
||||
textForUrl="Enterprise License."
|
||||
url={`/environments/${environmentId}/settings/enterprise`}
|
||||
/>
|
||||
) : !isMultiLanguageAllowed && isFormbricksCloud && !isMultiLanguageActivated ? (
|
||||
<UpgradePlanNotice
|
||||
message="To enable multi-language surveys,"
|
||||
url={`/environments/${environmentId}/settings/billing`}
|
||||
textForUrl="please upgrade your plan."
|
||||
url={`/environments/${environmentId}/settings/billing`}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -238,14 +239,14 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
|
||||
{product.languages.length > 1 && (
|
||||
<div className="my-4 space-y-4">
|
||||
<div>
|
||||
{isMultiLanguageAllowed && !isMultiLanguageActivated && (
|
||||
{isMultiLanguageAllowed && !isMultiLanguageActivated ? (
|
||||
<div className="text-sm italic text-slate-500">
|
||||
Switch multi-lanugage on to get started 👉
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isMultiLanguageActivated && (
|
||||
{isMultiLanguageActivated ? (
|
||||
<div className="space-y-4">
|
||||
<DefaultLanguageSelect
|
||||
defaultLanguage={defaultLanguage}
|
||||
@@ -253,23 +254,23 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
|
||||
product={product}
|
||||
setConfirmationModalInfo={setConfirmationModalInfo}
|
||||
/>
|
||||
{defaultLanguage && (
|
||||
{defaultLanguage ? (
|
||||
<SecondaryLanguageSelect
|
||||
product={product}
|
||||
defaultLanguage={defaultLanguage}
|
||||
localSurvey={localSurvey}
|
||||
updateSurveyLanguages={updateSurveyLanguages}
|
||||
product={product}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
updateSurveyLanguages={updateSurveyLanguages}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link href={`/environments/${environmentId}/product/languages`} target="_blank">
|
||||
<Button className="mt-2" variant="secondary" size="sm">
|
||||
<Button className="mt-2" size="sm" variant="secondary">
|
||||
Manage Languages <ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -277,13 +278,15 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
|
||||
)}
|
||||
|
||||
<ConfirmationModal
|
||||
title={confirmationModalInfo.title}
|
||||
open={confirmationModalInfo.open}
|
||||
setOpen={() => setConfirmationModalInfo((prev) => ({ ...prev, open: !prev.open }))}
|
||||
text={confirmationModalInfo.text}
|
||||
onConfirm={confirmationModalInfo.onConfirm}
|
||||
buttonText={confirmationModalInfo.buttonText}
|
||||
buttonVariant={confirmationModalInfo.buttonVariant}
|
||||
onConfirm={confirmationModalInfo.onConfirm}
|
||||
open={confirmationModalInfo.open}
|
||||
setOpen={() => {
|
||||
setConfirmationModalInfo((prev) => ({ ...prev, open: !prev.open }));
|
||||
}}
|
||||
text={confirmationModalInfo.text}
|
||||
title={confirmationModalInfo.title}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
@@ -1,9 +1,9 @@
|
||||
import { TLanguage, TProduct } from "@formbricks/types/product";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import type { TLanguage, TProduct } from "@formbricks/types/product";
|
||||
import type { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
import { LanguageToggle } from "./LanguageToggle";
|
||||
import { LanguageToggle } from "./language-toggle";
|
||||
|
||||
interface secondaryLanguageSelectProps {
|
||||
interface SecondaryLanguageSelectProps {
|
||||
product: TProduct;
|
||||
defaultLanguage: TLanguage;
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
@@ -12,14 +12,14 @@ interface secondaryLanguageSelectProps {
|
||||
updateSurveyLanguages: (language: TLanguage) => void;
|
||||
}
|
||||
|
||||
export const SecondaryLanguageSelect = ({
|
||||
export function SecondaryLanguageSelect({
|
||||
product,
|
||||
defaultLanguage,
|
||||
setSelectedLanguageCode,
|
||||
setActiveQuestionId,
|
||||
localSurvey,
|
||||
updateSurveyLanguages,
|
||||
}: secondaryLanguageSelectProps) => {
|
||||
}: SecondaryLanguageSelectProps) {
|
||||
const isLanguageToggled = (language: TLanguage) => {
|
||||
return localSurvey.languages.some(
|
||||
(surveyLanguage) => surveyLanguage.language.code === language.code && surveyLanguage.enabled
|
||||
@@ -33,16 +33,18 @@ export const SecondaryLanguageSelect = ({
|
||||
.filter((lang) => lang.id !== defaultLanguage.id)
|
||||
.map((language) => (
|
||||
<LanguageToggle
|
||||
isChecked={isLanguageToggled(language)}
|
||||
key={language.id}
|
||||
language={language}
|
||||
isChecked={isLanguageToggled(language)}
|
||||
onToggle={() => updateSurveyLanguages(language)}
|
||||
onEdit={() => {
|
||||
setSelectedLanguageCode(language.code);
|
||||
setActiveQuestionId(localSurvey.questions[0]?.id);
|
||||
}}
|
||||
onToggle={() => {
|
||||
updateSurveyLanguages(language);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { canUserAccessProduct, verifyUserRoleAccess } from "@formbricks/lib/product/auth";
|
||||
import { getProduct } from "@formbricks/lib/product/service";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { TLanguageInput } from "@formbricks/types/product";
|
||||
import type { TLanguageInput } from "@formbricks/types/product";
|
||||
|
||||
export const createLanguageAction = async (
|
||||
productId: string,
|
||||
@@ -7,18 +7,31 @@
|
||||
"version": "1.0.0",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"clean": "rimraf node_modules .turbo"
|
||||
"clean": "rimraf node_modules .turbo",
|
||||
"lint": "eslint --ext .ts,.tsx --fix ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/lib": "*",
|
||||
"@formbricks/config-typescript": "*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@formbricks/lib": "*",
|
||||
"@formbricks/types": "*",
|
||||
"@formbricks/ui": "*",
|
||||
"@formbricks/eslint-config": "workspace:*"
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/react": "18.3.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/database": "workspace:*",
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"axios": "^1.7.2",
|
||||
"stripe": "^15.8.0"
|
||||
"lucide-react": "^0.390.0",
|
||||
"next": "^14.2.3",
|
||||
"next-auth": "^4.24.7",
|
||||
"react-hook-form": "^7.51.5",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"server-only": "^0.0.1",
|
||||
"stripe": "^15.8.0",
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
import type { Control } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import {
|
||||
@@ -17,24 +18,26 @@ enum MembershipRole {
|
||||
Viewer = "viewer",
|
||||
}
|
||||
|
||||
type AddMemberRoleProps = {
|
||||
control: Control<{ name: string; email: string; role: MembershipRole }, any>;
|
||||
interface AddMemberRoleProps {
|
||||
control: Control<{ name: string; email: string; role: MembershipRole }>;
|
||||
canDoRoleManagement: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const AddMemberRole = ({ control, canDoRoleManagement }: AddMemberRoleProps) => {
|
||||
export function AddMemberRole({ control, canDoRoleManagement }: AddMemberRoleProps) {
|
||||
return (
|
||||
<Controller
|
||||
name="role"
|
||||
control={control}
|
||||
name="role"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div>
|
||||
<Label>Role</Label>
|
||||
<Select
|
||||
defaultValue="admin"
|
||||
value={value}
|
||||
onValueChange={(v) => onChange(v as MembershipRole)}
|
||||
disabled={!canDoRoleManagement}>
|
||||
disabled={!canDoRoleManagement}
|
||||
onValueChange={(v) => {
|
||||
onChange(v as MembershipRole);
|
||||
}}
|
||||
value={value}>
|
||||
<SelectTrigger className="capitalize">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -52,4 +55,4 @@ export const AddMemberRole = ({ control, canDoRoleManagement }: AddMemberRolePro
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import type { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { Badge } from "@formbricks/ui/Badge";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import {
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
|
||||
import { transferOwnershipAction, updateInviteAction, updateMembershipAction } from "../lib/actions";
|
||||
import { TransferOwnershipModal } from "./TransferOwnershipModal";
|
||||
import { TransferOwnershipModal } from "./transfer-ownership-modal";
|
||||
|
||||
interface Role {
|
||||
isAdminOrOwner: boolean;
|
||||
@@ -32,7 +32,7 @@ interface Role {
|
||||
currentUserRole: string;
|
||||
}
|
||||
|
||||
export const EditMembershipRole = ({
|
||||
export function EditMembershipRole({
|
||||
isAdminOrOwner,
|
||||
memberRole,
|
||||
organizationId,
|
||||
@@ -42,7 +42,7 @@ export const EditMembershipRole = ({
|
||||
memberAccepted,
|
||||
inviteId,
|
||||
currentUserRole,
|
||||
}: Role) => {
|
||||
}: Role) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isTransferOwnershipModalOpen, setTransferOwnershipModalOpen] = useState(false);
|
||||
@@ -110,11 +110,11 @@ export const EditMembershipRole = ({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
disabled={disableRole}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1 p-2 text-xs"
|
||||
disabled={disableRole}
|
||||
loading={loading}
|
||||
size="sm">
|
||||
size="sm"
|
||||
variant="secondary">
|
||||
<span className="ml-1">{capitalizeFirstLetter(memberRole)}</span>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -122,10 +122,12 @@ export const EditMembershipRole = ({
|
||||
{!disableRole && (
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuRadioGroup
|
||||
value={capitalizeFirstLetter(memberRole)}
|
||||
onValueChange={(value) => handleRoleChange(value.toLowerCase() as TMembershipRole)}>
|
||||
onValueChange={(value) => {
|
||||
handleRoleChange(value.toLowerCase() as TMembershipRole);
|
||||
}}
|
||||
value={capitalizeFirstLetter(memberRole)}>
|
||||
{getMembershipRoles().map((role) => (
|
||||
<DropdownMenuRadioItem key={role} value={role} className="capitalize">
|
||||
<DropdownMenuRadioItem className="capitalize" key={role} value={role}>
|
||||
{role.toLowerCase()}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
@@ -134,15 +136,15 @@ export const EditMembershipRole = ({
|
||||
)}
|
||||
</DropdownMenu>
|
||||
<TransferOwnershipModal
|
||||
open={isTransferOwnershipModalOpen}
|
||||
setOpen={setTransferOwnershipModalOpen}
|
||||
isLoading={loading}
|
||||
memberName={memberName}
|
||||
onSubmit={handleOwnershipTransfer}
|
||||
isLoading={loading}
|
||||
open={isTransferOwnershipModalOpen}
|
||||
setOpen={setTransferOwnershipModalOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <Badge text={capitalizeFirstLetter(memberRole)} type="gray" size="tiny" />;
|
||||
};
|
||||
return <Badge size="tiny" text={capitalizeFirstLetter(memberRole)} type="gray" />;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { CustomDialog } from "@formbricks/ui/CustomDialog";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
@@ -13,13 +14,13 @@ interface TransferOwnershipModalProps {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const TransferOwnershipModal = ({
|
||||
export function TransferOwnershipModal({
|
||||
setOpen,
|
||||
open,
|
||||
memberName,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
}: TransferOwnershipModalProps) => {
|
||||
}: TransferOwnershipModalProps) {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -28,14 +29,14 @@ export const TransferOwnershipModal = ({
|
||||
|
||||
return (
|
||||
<CustomDialog
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
onOk={onSubmit}
|
||||
okBtnText="Transfer ownership"
|
||||
title="There can only be ONE owner! Are you sure?"
|
||||
cancelBtnText="CANCEL"
|
||||
disabled={inputValue !== memberName}
|
||||
isLoading={isLoading}>
|
||||
isLoading={isLoading}
|
||||
okBtnText="Transfer ownership"
|
||||
onOk={onSubmit}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
title="There can only be ONE owner! Are you sure?">
|
||||
<div className="py-5">
|
||||
<ul className="list-disc pb-6 pl-6">
|
||||
<li>
|
||||
@@ -49,16 +50,16 @@ export const TransferOwnershipModal = ({
|
||||
Type in <b>{memberName}</b> to confirm:
|
||||
</label>
|
||||
<Input
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
placeholder={memberName}
|
||||
className="mt-5"
|
||||
type="text"
|
||||
id="transferOwnershipConfirmation"
|
||||
name="transferOwnershipConfirmation"
|
||||
onChange={handleInputChange}
|
||||
placeholder={memberName}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</CustomDialog>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
updateMembership,
|
||||
} from "@formbricks/lib/membership/service";
|
||||
import { AuthenticationError, AuthorizationError, ValidationError } from "@formbricks/types/errors";
|
||||
import { TInviteUpdateInput } from "@formbricks/types/invites";
|
||||
import { TMembershipUpdateInput } from "@formbricks/types/memberships";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import type { TInviteUpdateInput } from "@formbricks/types/invites";
|
||||
import type { TMembershipUpdateInput } from "@formbricks/types/memberships";
|
||||
import type { TUser } from "@formbricks/types/user";
|
||||
|
||||
export const transferOwnershipAction = async (organizationId: string, newOwnerId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "@formbricks/config-typescript/react-library.json",
|
||||
"extends": "@formbricks/config-typescript/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
@@ -7,6 +7,6 @@
|
||||
},
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [".", "../ui/TargetingIndicator.tsx"],
|
||||
"include": [".", "../../packages/types/*.d.ts"],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
module.exports = {
|
||||
extends: ["@formbricks/eslint-config/legacy-next.js"],
|
||||
extends: ["@formbricks/eslint-config/react.js"],
|
||||
};
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import { Container, Heading, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { EmailButton } from "../general/EmailButton";
|
||||
import { EmailFooter } from "../general/EmailFooter";
|
||||
import { EmailButton } from "../general/email-button";
|
||||
import { EmailFooter } from "../general/email-footer";
|
||||
|
||||
interface ForgotPasswordEmailProps {
|
||||
verifyLink: string;
|
||||
}
|
||||
|
||||
export const ForgotPasswordEmail = ({ verifyLink }: ForgotPasswordEmailProps) => {
|
||||
export function ForgotPasswordEmail({ verifyLink }: ForgotPasswordEmailProps) {
|
||||
return (
|
||||
<Container>
|
||||
<Heading>Change password</Heading>
|
||||
<Text>
|
||||
You have requested a link to change your password. You can do this by clicking the link below:
|
||||
</Text>
|
||||
<EmailButton label={"Change password"} href={verifyLink} />
|
||||
<EmailButton href={verifyLink} label="Change password" />
|
||||
<Text className="font-bold">The link is valid for 24 hours.</Text>
|
||||
<Text className="mb-0">If you didn't request this, please ignore this email.</Text>
|
||||
<EmailFooter />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Container, Heading, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
import { EmailFooter } from "../general/email-footer";
|
||||
|
||||
import { EmailFooter } from "../general/EmailFooter";
|
||||
|
||||
export const PasswordResetNotifyEmail = () => {
|
||||
export function PasswordResetNotifyEmail() {
|
||||
return (
|
||||
<Container>
|
||||
<Heading>Password changed</Heading>
|
||||
@@ -11,4 +10,4 @@ export const PasswordResetNotifyEmail = () => {
|
||||
<EmailFooter />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,32 +1,31 @@
|
||||
import { Container, Heading, Link, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { EmailButton } from "../general/EmailButton";
|
||||
import { EmailFooter } from "../general/EmailFooter";
|
||||
import { EmailButton } from "../general/email-button";
|
||||
import { EmailFooter } from "../general/email-footer";
|
||||
|
||||
interface VerificationEmailProps {
|
||||
verifyLink: string;
|
||||
verificationRequestLink: string;
|
||||
}
|
||||
|
||||
export const VerificationEmail = ({ verifyLink, verificationRequestLink }: VerificationEmailProps) => {
|
||||
export function VerificationEmail({ verifyLink, verificationRequestLink }: VerificationEmailProps) {
|
||||
return (
|
||||
<Container>
|
||||
<Heading>Almost there!</Heading>
|
||||
<Text>To start using Formbricks please verify your email below:</Text>
|
||||
<EmailButton href={verifyLink} label={"Verify email"} />
|
||||
<EmailButton href={verifyLink} label="Verify email" />
|
||||
<Text>You can also click on this link:</Text>
|
||||
<Link href={verifyLink} className="break-all text-black">
|
||||
<Link className="break-all text-black" href={verifyLink}>
|
||||
{verifyLink}
|
||||
</Link>
|
||||
<Text className="font-bold">The link is valid for 24h.</Text>
|
||||
<Text>
|
||||
If it has expired please request a new token here:{" "}
|
||||
<Link href={verificationRequestLink} className="text-black underline">
|
||||
<Link className="text-black underline" href={verificationRequestLink}>
|
||||
Request new verification
|
||||
</Link>
|
||||
</Text>
|
||||
<EmailFooter />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { Body, Column, Container, Html, Img, Link, Row, Section, Tailwind } from "@react-email/components";
|
||||
import React, { Fragment } from "react";
|
||||
|
||||
interface EmailTemplateProps {
|
||||
content: JSX.Element;
|
||||
}
|
||||
|
||||
export const EmailTemplate = ({ content }: EmailTemplateProps) => (
|
||||
<Html>
|
||||
<Tailwind>
|
||||
<Fragment>
|
||||
<Body
|
||||
className="m-0 h-full w-full justify-center bg-slate-100 bg-slate-50 p-6 text-center text-base font-medium text-slate-800"
|
||||
style={{
|
||||
fontFamily: "'Jost', 'Helvetica Neue', 'Segoe UI', 'Helvetica', 'sans-serif'",
|
||||
}}>
|
||||
<Section>
|
||||
<Link href="https://formbricks.com?utm_source=email_header&utm_medium=email" target="_blank">
|
||||
<Img
|
||||
src="https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Formbricks-Light-transparent.png"
|
||||
alt="Formbricks Logo"
|
||||
className="mx-auto w-80"
|
||||
/>
|
||||
</Link>
|
||||
</Section>
|
||||
<Container className="mx-auto my-8 max-w-xl bg-white p-4 text-left">{content}</Container>
|
||||
|
||||
<Section>
|
||||
<Row>
|
||||
<Column align="right" key="twitter">
|
||||
<Link target="_blank" href="https://twitter.com/formbricks">
|
||||
<Img
|
||||
title="Twitter"
|
||||
src="https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Twitter-transp.png"
|
||||
alt="Tw"
|
||||
width="32"
|
||||
/>
|
||||
</Link>
|
||||
</Column>
|
||||
<Column align="center" className="w-20" key="github">
|
||||
<Link target="_blank" href="https://formbricks.com/github">
|
||||
<Img
|
||||
title="GitHub"
|
||||
src="https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Github-transp.png"
|
||||
alt="GitHub"
|
||||
width="32"
|
||||
/>
|
||||
</Link>
|
||||
</Column>
|
||||
<Column align="left" key="discord">
|
||||
<Link target="_blank" href="https://formbricks.com/discord">
|
||||
<Img
|
||||
title="Discord"
|
||||
src="https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Discord-transp.png"
|
||||
alt="Discord"
|
||||
width="32"
|
||||
/>
|
||||
</Link>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
<Section className="mt-4 text-center">
|
||||
Formbricks {new Date().getFullYear()}. All rights reserved.
|
||||
<br />
|
||||
<Link
|
||||
href="https://formbricks.com/imprint?utm_source=email_footer&utm_medium=email"
|
||||
target="_blank">
|
||||
Imprint
|
||||
</Link>{" "}
|
||||
|{" "}
|
||||
<Link
|
||||
href="https://formbricks.com/privacy-policy?utm_source=email_footer&utm_medium=email"
|
||||
target="_blank">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</Section>
|
||||
</Body>
|
||||
</Fragment>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
@@ -6,10 +6,10 @@ interface EmailButtonProps {
|
||||
href: string;
|
||||
}
|
||||
|
||||
export const EmailButton = ({ label, href }: EmailButtonProps) => {
|
||||
export function EmailButton({ label, href }: EmailButtonProps) {
|
||||
return (
|
||||
<Button className="rounded-md bg-black p-4 text-white" href={href}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
export const EmailFooter = () => {
|
||||
export function EmailFooter() {
|
||||
return (
|
||||
<Text>
|
||||
Have a great day!<br></br> The Formbricks Team!
|
||||
Have a great day!
|
||||
<br /> The Formbricks Team!
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
}
|
||||
82
packages/email/components/general/email-template.tsx
Normal file
82
packages/email/components/general/email-template.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Body, Column, Container, Html, Img, Link, Row, Section, Tailwind } from "@react-email/components";
|
||||
|
||||
interface EmailTemplateProps {
|
||||
content: JSX.Element;
|
||||
}
|
||||
|
||||
export function EmailTemplate({ content }: EmailTemplateProps) {
|
||||
return (
|
||||
<Html>
|
||||
<Tailwind>
|
||||
<>
|
||||
<Body
|
||||
className="m-0 h-full w-full justify-center bg-slate-100 bg-slate-50 p-6 text-center text-base font-medium text-slate-800"
|
||||
style={{
|
||||
fontFamily: "'Jost', 'Helvetica Neue', 'Segoe UI', 'Helvetica', 'sans-serif'",
|
||||
}}>
|
||||
<Section>
|
||||
<Link href="https://formbricks.com?utm_source=email_header&utm_medium=email" target="_blank">
|
||||
<Img
|
||||
alt="Formbricks Logo"
|
||||
className="mx-auto w-80"
|
||||
src="https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Formbricks-Light-transparent.png"
|
||||
/>
|
||||
</Link>
|
||||
</Section>
|
||||
<Container className="mx-auto my-8 max-w-xl bg-white p-4 text-left">{content}</Container>
|
||||
|
||||
<Section>
|
||||
<Row>
|
||||
<Column align="right" key="twitter">
|
||||
<Link href="https://twitter.com/formbricks" target="_blank">
|
||||
<Img
|
||||
alt="Tw"
|
||||
src="https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Twitter-transp.png"
|
||||
title="Twitter"
|
||||
width="32"
|
||||
/>
|
||||
</Link>
|
||||
</Column>
|
||||
<Column align="center" className="w-20" key="github">
|
||||
<Link href="https://formbricks.com/github" target="_blank">
|
||||
<Img
|
||||
alt="GitHub"
|
||||
src="https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Github-transp.png"
|
||||
title="GitHub"
|
||||
width="32"
|
||||
/>
|
||||
</Link>
|
||||
</Column>
|
||||
<Column align="left" key="discord">
|
||||
<Link href="https://formbricks.com/discord" target="_blank">
|
||||
<Img
|
||||
alt="Discord"
|
||||
src="https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Discord-transp.png"
|
||||
title="Discord"
|
||||
width="32"
|
||||
/>
|
||||
</Link>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
<Section className="mt-4 text-center">
|
||||
Formbricks {new Date().getFullYear()}. All rights reserved.
|
||||
<br />
|
||||
<Link
|
||||
href="https://formbricks.com/imprint?utm_source=email_footer&utm_medium=email"
|
||||
target="_blank">
|
||||
Imprint
|
||||
</Link>{" "}
|
||||
|{" "}
|
||||
<Link
|
||||
href="https://formbricks.com/privacy-policy?utm_source=email_footer&utm_medium=email"
|
||||
target="_blank">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</Section>
|
||||
</Body>
|
||||
</>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Container, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { EmailFooter } from "../general/EmailFooter";
|
||||
import { EmailFooter } from "../general/email-footer";
|
||||
|
||||
interface InviteAcceptedEmailProps {
|
||||
inviterName: string;
|
||||
inviteeName: string;
|
||||
}
|
||||
|
||||
export const InviteAcceptedEmail = ({ inviterName, inviteeName }: InviteAcceptedEmailProps) => {
|
||||
export function InviteAcceptedEmail({ inviterName, inviteeName }: InviteAcceptedEmailProps) {
|
||||
return (
|
||||
<Container>
|
||||
<Text>Hey {inviterName},</Text>
|
||||
@@ -16,4 +15,4 @@ export const InviteAcceptedEmail = ({ inviterName, inviteeName }: InviteAccepted
|
||||
<EmailFooter />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Container, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { EmailButton } from "../general/EmailButton";
|
||||
import { EmailFooter } from "../general/EmailFooter";
|
||||
import { EmailButton } from "../general/email-button";
|
||||
import { EmailFooter } from "../general/email-footer";
|
||||
|
||||
interface InviteEmailProps {
|
||||
inviteeName: string;
|
||||
@@ -10,7 +9,7 @@ interface InviteEmailProps {
|
||||
verifyLink: string;
|
||||
}
|
||||
|
||||
export const InviteEmail = ({ inviteeName, inviterName, verifyLink }: InviteEmailProps) => {
|
||||
export function InviteEmail({ inviteeName, inviterName, verifyLink }: InviteEmailProps) {
|
||||
return (
|
||||
<Container>
|
||||
<Text>Hey {inviteeName},</Text>
|
||||
@@ -18,8 +17,8 @@ export const InviteEmail = ({ inviteeName, inviterName, verifyLink }: InviteEmai
|
||||
Your colleague {inviterName} invited you to join them at Formbricks. To accept the invitation, please
|
||||
click the link below:
|
||||
</Text>
|
||||
<EmailButton label="Join organization" href={verifyLink} />
|
||||
<EmailButton href={verifyLink} label="Join organization" />
|
||||
<EmailFooter />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Container, Heading, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { EmailButton } from "../general/EmailButton";
|
||||
import { EmailFooter } from "../general/EmailFooter";
|
||||
import { EmailButton } from "../general/email-button";
|
||||
import { EmailFooter } from "../general/email-footer";
|
||||
|
||||
interface OnboardingInviteEmailProps {
|
||||
inviteMessage: string;
|
||||
@@ -10,11 +9,11 @@ interface OnboardingInviteEmailProps {
|
||||
verifyLink: string;
|
||||
}
|
||||
|
||||
export const OnboardingInviteEmail = ({
|
||||
export function OnboardingInviteEmail({
|
||||
inviteMessage,
|
||||
inviterName,
|
||||
verifyLink,
|
||||
}: OnboardingInviteEmailProps) => {
|
||||
}: OnboardingInviteEmailProps) {
|
||||
return (
|
||||
<Container>
|
||||
<Heading>Hey 👋</Heading>
|
||||
@@ -25,8 +24,8 @@ export const OnboardingInviteEmail = ({
|
||||
<li>Connect Formbricks to your app or website via HTML Snippet or NPM in just a few minutes.</li>
|
||||
<li>Done ✅</li>
|
||||
</ol>
|
||||
<EmailButton label={`Join ${inviterName}'s organization`} href={verifyLink} />
|
||||
<EmailButton href={verifyLink} label={`Join ${inviterName}'s organization`} />
|
||||
<EmailFooter />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,7 @@ interface EmbedSurveyPreviewEmailProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const EmbedSurveyPreviewEmail = ({ html, environmentId }: EmbedSurveyPreviewEmailProps) => {
|
||||
export function EmbedSurveyPreviewEmail({ html, environmentId }: EmbedSurveyPreviewEmailProps) {
|
||||
return (
|
||||
<Container>
|
||||
<Heading>Preview Email Embed</Heading>
|
||||
@@ -14,8 +14,8 @@ export const EmbedSurveyPreviewEmail = ({ html, environmentId }: EmbedSurveyPrev
|
||||
<Text className="text-sm">
|
||||
<b>Didn't request this?</b> Help us fight spam and forward this mail to hola@formbricks.com
|
||||
</Text>
|
||||
<div dangerouslySetInnerHTML={{ __html: html }}></div>
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
<Text className="text-center text-sm text-slate-700">Environment ID: {environmentId}</Text>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Container, Heading, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { EmailButton } from "../general/EmailButton";
|
||||
import { EmailFooter } from "../general/EmailFooter";
|
||||
import { EmailButton } from "../general/email-button";
|
||||
import { EmailFooter } from "../general/email-footer";
|
||||
|
||||
interface LinkSurveyEmailProps {
|
||||
surveyData?:
|
||||
@@ -15,15 +14,15 @@ interface LinkSurveyEmailProps {
|
||||
getSurveyLink: () => string;
|
||||
}
|
||||
|
||||
export const LinkSurveyEmail = ({ surveyData, getSurveyLink }: LinkSurveyEmailProps) => {
|
||||
export function LinkSurveyEmail({ surveyData, getSurveyLink }: LinkSurveyEmailProps) {
|
||||
return (
|
||||
<Container>
|
||||
<Heading>Hey 👋</Heading>
|
||||
<Text>Thanks for validating your email. Here is your Survey.</Text>
|
||||
<Text className="font-bold">{surveyData?.name}</Text>
|
||||
<Text>{surveyData?.subheading}</Text>
|
||||
<EmailButton label="Take survey" href={getSurveyLink()} />
|
||||
<EmailButton href={getSurveyLink()} label="Take survey" />
|
||||
<EmailFooter />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -12,12 +12,12 @@ import {
|
||||
import { render } from "@react-email/render";
|
||||
import { CalendarDaysIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
|
||||
import { isLight, mixColor } from "@formbricks/lib/utils/colors";
|
||||
import { TSurvey, TSurveyQuestionType, TSurveyStyling } from "@formbricks/types/surveys";
|
||||
import type { TSurvey, TSurveyStyling } from "@formbricks/types/surveys";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys";
|
||||
import { RatingSmiley } from "@formbricks/ui/RatingSmiley";
|
||||
|
||||
interface PreviewEmailTemplateProps {
|
||||
@@ -27,23 +27,23 @@ interface PreviewEmailTemplateProps {
|
||||
}
|
||||
|
||||
export const getPreviewEmailTemplateHtml = (survey: TSurvey, surveyUrl: string, styling: TSurveyStyling) => {
|
||||
return render(<PreviewEmailTemplate survey={survey} surveyUrl={surveyUrl} styling={styling} />, {
|
||||
return render(<PreviewEmailTemplate styling={styling} survey={survey} surveyUrl={surveyUrl} />, {
|
||||
pretty: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const PreviewEmailTemplate = ({ survey, surveyUrl, styling }: PreviewEmailTemplateProps) => {
|
||||
export function PreviewEmailTemplate({ survey, surveyUrl, styling }: PreviewEmailTemplateProps) {
|
||||
const url = `${surveyUrl}?preview=true`;
|
||||
const urlWithPrefilling = `${surveyUrl}?preview=true&skipPrefilled=true&`;
|
||||
const defaultLanguageCode = "default";
|
||||
const firstQuestion = survey.questions[0];
|
||||
|
||||
const brandColor = styling?.brandColor?.light || COLOR_DEFAULTS.brandColor;
|
||||
const brandColor = styling.brandColor?.light || COLOR_DEFAULTS.brandColor;
|
||||
|
||||
switch (firstQuestion.type) {
|
||||
case TSurveyQuestionType.OpenText:
|
||||
case TSurveyQuestionTypeEnum.OpenText:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} styling={styling}>
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Text className="text-question-color m-0 mr-8 block p-0 text-base font-semibold leading-6">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
@@ -54,9 +54,9 @@ export const PreviewEmailTemplate = ({ survey, surveyUrl, styling }: PreviewEmai
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.Consent:
|
||||
case TSurveyQuestionTypeEnum.Consent:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} styling={styling}>
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Text className="text-question-color m-0 block text-base font-semibold leading-6">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
@@ -65,7 +65,8 @@ export const PreviewEmailTemplate = ({ survey, surveyUrl, styling }: PreviewEmai
|
||||
className="m-0 p-0"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getLocalizedValue(firstQuestion.html, defaultLanguageCode) || "",
|
||||
}}></Text>
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
|
||||
<Container className="border-input-border-color bg-input-color rounded-custom m-0 mt-4 block w-full max-w-none border border-solid p-4 font-medium text-slate-800">
|
||||
@@ -76,26 +77,26 @@ export const PreviewEmailTemplate = ({ survey, surveyUrl, styling }: PreviewEmai
|
||||
<Container className="mx-0 mt-4 flex max-w-none justify-end">
|
||||
{!firstQuestion.required && (
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
|
||||
className="rounded-custom inline-flex cursor-pointer appearance-none px-6 py-3 text-sm font-medium text-black">
|
||||
className="rounded-custom inline-flex cursor-pointer appearance-none px-6 py-3 text-sm font-medium text-black"
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}>
|
||||
Reject
|
||||
</EmailButton>
|
||||
)}
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=accepted`}
|
||||
className={cn(
|
||||
"bg-brand-color rounded-custom ml-2 inline-flex cursor-pointer appearance-none px-6 py-3 text-sm font-medium",
|
||||
isLight(brandColor) ? "text-black" : "text-white"
|
||||
)}>
|
||||
)}
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=accepted`}>
|
||||
Accept
|
||||
</EmailButton>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.NPS:
|
||||
case TSurveyQuestionTypeEnum.NPS:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} styling={styling}>
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Section>
|
||||
<Text className="text-question-color m-0 block text-base font-semibold leading-6">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
@@ -107,9 +108,9 @@ export const PreviewEmailTemplate = ({ survey, surveyUrl, styling }: PreviewEmai
|
||||
<Section className="border-input-border-color rounded-custom block overflow-hidden border">
|
||||
{Array.from({ length: 11 }, (_, i) => (
|
||||
<EmailButton
|
||||
key={i}
|
||||
className="border-input-border-color m-0 inline-flex h-10 w-10 items-center justify-center border p-0 text-slate-800"
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${i}`}
|
||||
className="border-input-border-color m-0 inline-flex h-10 w-10 items-center justify-center border p-0 text-slate-800">
|
||||
key={i}>
|
||||
{i}
|
||||
</EmailButton>
|
||||
))}
|
||||
@@ -133,9 +134,9 @@ export const PreviewEmailTemplate = ({ survey, surveyUrl, styling }: PreviewEmai
|
||||
</Section>
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.CTA:
|
||||
case TSurveyQuestionTypeEnum.CTA:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} styling={styling}>
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Text className="text-question-color m-0 block text-base font-semibold leading-6">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
@@ -144,32 +145,33 @@ export const PreviewEmailTemplate = ({ survey, surveyUrl, styling }: PreviewEmai
|
||||
className="m-0 p-0"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getLocalizedValue(firstQuestion.html, defaultLanguageCode) || "",
|
||||
}}></Text>
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
|
||||
<Container className="mx-0 mt-4 max-w-none">
|
||||
{!firstQuestion.required && (
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
|
||||
className="rounded-custom inline-flex cursor-pointer appearance-none px-6 py-3 text-sm font-medium text-black">
|
||||
className="rounded-custom inline-flex cursor-pointer appearance-none px-6 py-3 text-sm font-medium text-black"
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}>
|
||||
{getLocalizedValue(firstQuestion.dismissButtonLabel, defaultLanguageCode) || "Skip"}
|
||||
</EmailButton>
|
||||
)}
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=clicked`}
|
||||
className={cn(
|
||||
"bg-brand-color rounded-custom inline-flex cursor-pointer appearance-none px-6 py-3 text-sm font-medium",
|
||||
isLight(brandColor) ? "text-black" : "text-white"
|
||||
)}>
|
||||
)}
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=clicked`}>
|
||||
{getLocalizedValue(firstQuestion.buttonLabel, defaultLanguageCode)}
|
||||
</EmailButton>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.Rating:
|
||||
case TSurveyQuestionTypeEnum.Rating:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} styling={styling}>
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Section className="w-full">
|
||||
<Text className="text-question-color m-0 block text-base font-semibold leading-6">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
@@ -180,19 +182,19 @@ export const PreviewEmailTemplate = ({ survey, surveyUrl, styling }: PreviewEmai
|
||||
<Container className="mx-0 mt-4 w-full items-center justify-center">
|
||||
<Section
|
||||
className={cn("rounded-custom w-full overflow-hidden", {
|
||||
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
|
||||
"border border-solid border-gray-200": firstQuestion.scale === "number",
|
||||
})}>
|
||||
<Column className="mb-4 flex w-full justify-around">
|
||||
{Array.from({ length: firstQuestion.range }, (_, i) => (
|
||||
<EmailButton
|
||||
key={i}
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${i + 1}`}
|
||||
className={cn(
|
||||
"m-0 h-10 w-full p-0 text-center align-middle leading-10 text-slate-800",
|
||||
{
|
||||
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
|
||||
"border border-solid border-gray-200": firstQuestion.scale === "number",
|
||||
}
|
||||
)}>
|
||||
)}
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${i + 1}`}
|
||||
key={i}>
|
||||
{firstQuestion.scale === "smiley" && (
|
||||
<RatingSmiley active={false} idx={i} range={firstQuestion.range} />
|
||||
)}
|
||||
@@ -223,9 +225,9 @@ export const PreviewEmailTemplate = ({ survey, surveyUrl, styling }: PreviewEmai
|
||||
</Section>
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.MultipleChoiceMulti:
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} styling={styling}>
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Text className="text-question-color m-0 mr-8 block p-0 text-base font-semibold leading-6">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
@@ -244,9 +246,9 @@ export const PreviewEmailTemplate = ({ survey, surveyUrl, styling }: PreviewEmai
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.MultipleChoiceSingle:
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} styling={styling}>
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Text className="text-question-color m-0 mr-8 block p-0 text-base font-semibold leading-6">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
@@ -256,9 +258,9 @@ export const PreviewEmailTemplate = ({ survey, surveyUrl, styling }: PreviewEmai
|
||||
<Container className="mx-0 max-w-none">
|
||||
{firstQuestion.choices.map((choice) => (
|
||||
<Link
|
||||
key={choice.id}
|
||||
className="border-input-border-color bg-input-color text-question-color rounded-custom mt-2 block border border-solid p-4 hover:bg-slate-100"
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${getLocalizedValue(choice.label, defaultLanguageCode)}`}>
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${getLocalizedValue(choice.label, defaultLanguageCode)}`}
|
||||
key={choice.id}>
|
||||
{getLocalizedValue(choice.label, defaultLanguageCode)}
|
||||
</Link>
|
||||
))}
|
||||
@@ -266,9 +268,9 @@ export const PreviewEmailTemplate = ({ survey, surveyUrl, styling }: PreviewEmai
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.PictureSelection:
|
||||
case TSurveyQuestionTypeEnum.PictureSelection:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} styling={styling}>
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Text className="text-question-color m-0 mr-8 block p-0 text-base font-semibold leading-6">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
@@ -279,15 +281,17 @@ export const PreviewEmailTemplate = ({ survey, surveyUrl, styling }: PreviewEmai
|
||||
{firstQuestion.choices.map((choice) =>
|
||||
firstQuestion.allowMulti ? (
|
||||
<Img
|
||||
src={choice.imageUrl}
|
||||
className="rounded-custom mb-1 mr-1 inline-block h-[110px] w-[220px]"
|
||||
key={choice.id}
|
||||
src={choice.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
className="rounded-custom mb-1 mr-1 inline-block h-[110px] w-[220px]"
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
|
||||
target="_blank"
|
||||
className="rounded-custom mb-1 mr-1 inline-block h-[110px] w-[220px]">
|
||||
<Img src={choice.imageUrl} className="rounded-custom h-full w-full" />
|
||||
key={choice.id}
|
||||
target="_blank">
|
||||
<Img className="rounded-custom h-full w-full" src={choice.imageUrl} />
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
@@ -295,9 +299,9 @@ export const PreviewEmailTemplate = ({ survey, surveyUrl, styling }: PreviewEmai
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.Cal:
|
||||
case TSurveyQuestionTypeEnum.Cal:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} styling={styling}>
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Container>
|
||||
<Text className="text-question-color m-0 mb-2 block p-0 text-sm font-normal leading-6">
|
||||
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
|
||||
@@ -316,9 +320,9 @@ export const PreviewEmailTemplate = ({ survey, surveyUrl, styling }: PreviewEmai
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.Date:
|
||||
case TSurveyQuestionTypeEnum.Date:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} styling={styling}>
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Text className="text-question-color m-0 mr-8 block p-0 text-base font-semibold leading-6">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
@@ -332,9 +336,9 @@ export const PreviewEmailTemplate = ({ survey, surveyUrl, styling }: PreviewEmai
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.Matrix:
|
||||
case TSurveyQuestionTypeEnum.Matrix:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} styling={styling}>
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Text className="text-question-color m-0 mr-8 block p-0 text-base font-semibold leading-6">
|
||||
{getLocalizedValue(firstQuestion.headline, "default")}
|
||||
</Text>
|
||||
@@ -344,12 +348,12 @@ export const PreviewEmailTemplate = ({ survey, surveyUrl, styling }: PreviewEmai
|
||||
<Container className="mx-0">
|
||||
<Section className="w-full table-auto">
|
||||
<Row>
|
||||
<Column className="w-40 break-words px-4 py-2"></Column>
|
||||
<Column className="w-40 break-words px-4 py-2" />
|
||||
{firstQuestion.columns.map((column, columnIndex) => {
|
||||
return (
|
||||
<Column
|
||||
key={columnIndex}
|
||||
className="text-question-color max-w-40 break-words px-4 py-2 text-center">
|
||||
className="text-question-color max-w-40 break-words px-4 py-2 text-center"
|
||||
key={columnIndex}>
|
||||
{getLocalizedValue(column, "default")}
|
||||
</Column>
|
||||
);
|
||||
@@ -358,15 +362,15 @@ export const PreviewEmailTemplate = ({ survey, surveyUrl, styling }: PreviewEmai
|
||||
{firstQuestion.rows.map((row, rowIndex) => {
|
||||
return (
|
||||
<Row
|
||||
key={rowIndex}
|
||||
className={`${rowIndex % 2 === 0 ? "bg-input-color" : ""} rounded-custom`}>
|
||||
className={`${rowIndex % 2 === 0 ? "bg-input-color" : ""} rounded-custom`}
|
||||
key={rowIndex}>
|
||||
<Column className="w-40 break-words px-4 py-2">
|
||||
{getLocalizedValue(row, "default")}
|
||||
</Column>
|
||||
{firstQuestion.columns.map(() => {
|
||||
{firstQuestion.columns.map((_, index) => {
|
||||
return (
|
||||
<Column className="text-question-color px-4 py-2">
|
||||
<Section className="bg-card-bg-color h-4 w-4 rounded-full p-2 outline"></Section>
|
||||
<Column className="text-question-color px-4 py-2" key={index}>
|
||||
<Section className="bg-card-bg-color h-4 w-4 rounded-full p-2 outline" />
|
||||
</Column>
|
||||
);
|
||||
})}
|
||||
@@ -378,9 +382,9 @@ export const PreviewEmailTemplate = ({ survey, surveyUrl, styling }: PreviewEmai
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.Address:
|
||||
case TSurveyQuestionTypeEnum.Address:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} styling={styling}>
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Text className="text-question-color m-0 mr-8 block p-0 text-base font-semibold leading-6">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
@@ -389,17 +393,30 @@ export const PreviewEmailTemplate = ({ survey, surveyUrl, styling }: PreviewEmai
|
||||
</Text>
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<Section
|
||||
key={index}
|
||||
className="border-input-border-color bg-input-color rounded-custom mt-4 block h-10 w-full border border-solid"
|
||||
key={index}
|
||||
/>
|
||||
))}
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.FileUpload:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Text className="text-question-color m-0 mr-8 block p-0 text-base font-semibold leading-6">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Text className="text-question-color m-0 block p-0 text-sm font-normal leading-6">
|
||||
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Section className="border-input-border-color rounded-custom mt-4 block h-20 w-full border border-solid bg-slate-50" />
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const EmailTemplateWrapper = ({
|
||||
function EmailTemplateWrapper({
|
||||
children,
|
||||
surveyUrl,
|
||||
styling,
|
||||
@@ -407,7 +424,7 @@ const EmailTemplateWrapper = ({
|
||||
children: React.ReactNode;
|
||||
surveyUrl: string;
|
||||
styling: TSurveyStyling;
|
||||
}) => {
|
||||
}) {
|
||||
let signatureColor = "";
|
||||
const colors = {
|
||||
"brand-color": styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
|
||||
@@ -434,27 +451,27 @@ const EmailTemplateWrapper = ({
|
||||
"signature-color": signatureColor,
|
||||
},
|
||||
borderRadius: {
|
||||
custom: (styling.roundness ?? 8).toString() + "px",
|
||||
custom: `${(styling.roundness ?? 8).toString()}px`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}>
|
||||
<Link
|
||||
className="bg-card-bg-color border-card-border-color rounded-custom mx-0 my-2 block overflow-auto border border-solid p-8 font-sans text-inherit"
|
||||
href={surveyUrl}
|
||||
target="_blank"
|
||||
className="bg-card-bg-color border-card-border-color rounded-custom mx-0 my-2 block overflow-auto border border-solid p-8 font-sans text-inherit">
|
||||
target="_blank">
|
||||
{children}
|
||||
</Link>
|
||||
</Tailwind>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const EmailFooter = () => {
|
||||
function EmailFooter() {
|
||||
return (
|
||||
<Container className="m-auto mt-8 text-center">
|
||||
<Link href="https://formbricks.com/" target="_blank" className="text-signature-color text-xs">
|
||||
<Link className="text-signature-color text-xs" href="https://formbricks.com/" target="_blank">
|
||||
Powered by Formbricks
|
||||
</Link>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,39 +1,42 @@
|
||||
import { Column, Container, Hr, Img, Link, Row, Section, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { getQuestionResponseMapping } from "@formbricks/lib/responses";
|
||||
import { getOriginalFileNameFromUrl } from "@formbricks/lib/storage/utils";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import type { TOrganization } from "@formbricks/types/organizations";
|
||||
import type { TResponse } from "@formbricks/types/responses";
|
||||
import type { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys";
|
||||
import { EmailButton } from "../general/email-button";
|
||||
|
||||
import { EmailButton } from "../general/EmailButton";
|
||||
|
||||
export const renderEmailResponseValue = (response: string | string[], questionType: string) => {
|
||||
export const renderEmailResponseValue = (response: string | string[], questionType: TSurveyQuestionType) => {
|
||||
switch (questionType) {
|
||||
case TSurveyQuestionType.FileUpload:
|
||||
case TSurveyQuestionTypeEnum.FileUpload:
|
||||
return (
|
||||
<Container>
|
||||
{typeof response !== "string" &&
|
||||
response.map((response) => (
|
||||
response.map((responseItem) => (
|
||||
<Link
|
||||
href={response}
|
||||
key={response}
|
||||
className="mt-2 flex flex-col items-center justify-center rounded-lg bg-gray-200 p-2 text-black shadow-sm">
|
||||
className="mt-2 flex flex-col items-center justify-center rounded-lg bg-gray-200 p-2 text-black shadow-sm"
|
||||
href={responseItem}
|
||||
key={responseItem}>
|
||||
<FileIcon />
|
||||
<Text className="mx-auto mb-0 truncate">{getOriginalFileNameFromUrl(response)}</Text>
|
||||
<Text className="mx-auto mb-0 truncate">{getOriginalFileNameFromUrl(responseItem)}</Text>
|
||||
</Link>
|
||||
))}
|
||||
</Container>
|
||||
);
|
||||
case TSurveyQuestionType.PictureSelection:
|
||||
case TSurveyQuestionTypeEnum.PictureSelection:
|
||||
return (
|
||||
<Container className="flex">
|
||||
<Row>
|
||||
{typeof response !== "string" &&
|
||||
response.map((response) => (
|
||||
<Column>
|
||||
<Img src={response} id={response} alt={response.split("/").pop()} className="m-2 h-28" />
|
||||
response.map((responseItem) => (
|
||||
<Column key={responseItem}>
|
||||
<Img
|
||||
alt={responseItem.split("/").pop()}
|
||||
className="m-2 h-28"
|
||||
id={responseItem}
|
||||
src={responseItem}
|
||||
/>
|
||||
</Column>
|
||||
))}
|
||||
</Row>
|
||||
@@ -54,14 +57,14 @@ interface ResponseFinishedEmailProps {
|
||||
organization: TOrganization | null;
|
||||
}
|
||||
|
||||
export const ResponseFinishedEmail = ({
|
||||
export function ResponseFinishedEmail({
|
||||
survey,
|
||||
responseCount,
|
||||
response,
|
||||
WEBAPP_URL,
|
||||
environmentId,
|
||||
organization,
|
||||
}: ResponseFinishedEmailProps) => {
|
||||
}: ResponseFinishedEmailProps) {
|
||||
const questions = getQuestionResponseMapping(survey, response);
|
||||
|
||||
return (
|
||||
@@ -117,23 +120,23 @@ export const ResponseFinishedEmail = ({
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const FileIcon = () => {
|
||||
function FileIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
className="lucide lucide-file"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="lucide lucide-file">
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
import { Container, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { TWeeklySummaryNotificationResponse } from "@formbricks/types/weeklySummary";
|
||||
|
||||
import { EmailButton } from "../general/EmailButton";
|
||||
import { NotificationFooter } from "./NotificationFooter";
|
||||
import type { TWeeklySummaryNotificationResponse } from "@formbricks/types/weeklySummary";
|
||||
import { EmailButton } from "../general/email-button";
|
||||
import { NotificationFooter } from "./notification-footer";
|
||||
|
||||
interface CreateReminderNotificationBodyProps {
|
||||
notificationData: TWeeklySummaryNotificationResponse;
|
||||
}
|
||||
|
||||
export const CreateReminderNotificationBody = ({ notificationData }: CreateReminderNotificationBodyProps) => {
|
||||
export function CreateReminderNotificationBody({ notificationData }: CreateReminderNotificationBodyProps) {
|
||||
return (
|
||||
<Container>
|
||||
<Text>
|
||||
@@ -20,8 +18,8 @@ export const CreateReminderNotificationBody = ({ notificationData }: CreateRemin
|
||||
</Text>
|
||||
<Text className="pt-4 font-bold">Don’t let a week pass without learning about your users:</Text>
|
||||
<EmailButton
|
||||
label="Setup a new survey"
|
||||
href={`${WEBAPP_URL}/environments/${notificationData.environmentId}/surveys?utm_source=weekly&utm_medium=email&utm_content=SetupANewSurveyCTA`}
|
||||
label="Setup a new survey"
|
||||
/>
|
||||
<Text className="pt-4">
|
||||
Need help finding the right survey for your product? Pick a 15-minute slot{" "}
|
||||
@@ -30,4 +28,4 @@ export const CreateReminderNotificationBody = ({ notificationData }: CreateRemin
|
||||
<NotificationFooter environmentId={notificationData.environmentId} />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Container, Hr, Link, Tailwind, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import {
|
||||
import type { TSurveyStatus } from "@formbricks/types/surveys";
|
||||
import type {
|
||||
TWeeklySummaryNotificationDataSurvey,
|
||||
TWeeklySummarySurveyResponseData,
|
||||
} from "@formbricks/types/weeklySummary";
|
||||
|
||||
import { EmailButton } from "../general/EmailButton";
|
||||
import { renderEmailResponseValue } from "../survey/ResponseFinishedEmail";
|
||||
import { EmailButton } from "../general/email-button";
|
||||
import { renderEmailResponseValue } from "../survey/response-finished-email";
|
||||
|
||||
const getButtonLabel = (count: number): string => {
|
||||
if (count === 1) {
|
||||
@@ -17,7 +16,7 @@ const getButtonLabel = (count: number): string => {
|
||||
return `View ${count > 2 ? count - 1 : "1"} more Response${count > 2 ? "s" : ""}`;
|
||||
};
|
||||
|
||||
const convertSurveyStatus = (status: string): string => {
|
||||
const convertSurveyStatus = (status: TSurveyStatus): string => {
|
||||
const statusMap = {
|
||||
inProgress: "In Progress",
|
||||
paused: "Paused",
|
||||
@@ -43,7 +42,7 @@ export const LiveSurveyNotification = ({ environmentId, surveys }: LiveSurveyNot
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
let surveyFields: JSX.Element[] = [];
|
||||
const surveyFields: JSX.Element[] = [];
|
||||
const responseCount = surveyResponses.length;
|
||||
|
||||
surveyResponses.forEach((surveyResponse, index) => {
|
||||
@@ -78,8 +77,8 @@ export const LiveSurveyNotification = ({ environmentId, surveys }: LiveSurveyNot
|
||||
<Container className="mt-12">
|
||||
<Text className="mb-0 inline">
|
||||
<Link
|
||||
href={`${WEBAPP_URL}/environments/${environmentId}/surveys/${survey.id}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA`}
|
||||
className="text-xl text-black underline">
|
||||
className="text-xl text-black underline"
|
||||
href={`${WEBAPP_URL}/environments/${environmentId}/surveys/${survey.id}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA`}>
|
||||
{survey.name}
|
||||
</Link>
|
||||
</Text>
|
||||
@@ -96,8 +95,8 @@ export const LiveSurveyNotification = ({ environmentId, surveys }: LiveSurveyNot
|
||||
{survey.responseCount > 0 && (
|
||||
<Container className="mt-4 block">
|
||||
<EmailButton
|
||||
label={noResponseLastWeek ? "View previous responses" : getButtonLabel(survey.responseCount)}
|
||||
href={`${WEBAPP_URL}/environments/${environmentId}/surveys/${survey.id}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA`}
|
||||
label={noResponseLastWeek ? "View previous responses" : getButtonLabel(survey.responseCount)}
|
||||
/>
|
||||
</Container>
|
||||
)}
|
||||
@@ -1,9 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
import { TWeeklySummaryNotificationResponse } from "@formbricks/types/weeklySummary";
|
||||
|
||||
import { CreateReminderNotificationBody } from "./CreateReminderNotificationBody";
|
||||
import { NotificationHeader } from "./NotificationHeader";
|
||||
import type { TWeeklySummaryNotificationResponse } from "@formbricks/types/weeklySummary";
|
||||
import { CreateReminderNotificationBody } from "./create-reminder-notification-body";
|
||||
import { NotificationHeader } from "./notification-header";
|
||||
|
||||
interface NoLiveSurveyNotificationEmailProps {
|
||||
notificationData: TWeeklySummaryNotificationResponse;
|
||||
@@ -13,23 +11,23 @@ interface NoLiveSurveyNotificationEmailProps {
|
||||
endYear: number;
|
||||
}
|
||||
|
||||
export const NoLiveSurveyNotificationEmail = ({
|
||||
export function NoLiveSurveyNotificationEmail({
|
||||
notificationData,
|
||||
startDate,
|
||||
endDate,
|
||||
startYear,
|
||||
endYear,
|
||||
}: NoLiveSurveyNotificationEmailProps) => {
|
||||
}: NoLiveSurveyNotificationEmailProps) {
|
||||
return (
|
||||
<div>
|
||||
<NotificationHeader
|
||||
endDate={endDate}
|
||||
endYear={endYear}
|
||||
productName={notificationData.productName}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
startYear={startYear}
|
||||
endYear={endYear}
|
||||
/>
|
||||
<CreateReminderNotificationBody notificationData={notificationData} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Container, Link, Text } from "@react-email/components";
|
||||
import { Tailwind } from "@react-email/components";
|
||||
import { Container, Link, Tailwind, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
|
||||
interface NotificatonFooterProps {
|
||||
environmentId: string;
|
||||
}
|
||||
export const NotificationFooter = ({ environmentId }: NotificatonFooterProps) => {
|
||||
export function NotificationFooter({ environmentId }: NotificatonFooterProps) {
|
||||
return (
|
||||
<Tailwind>
|
||||
<Container className="w-full">
|
||||
@@ -19,8 +17,8 @@ export const NotificationFooter = ({ environmentId }: NotificatonFooterProps) =>
|
||||
<Text>
|
||||
To halt Weekly Updates,{" "}
|
||||
<Link
|
||||
href={`${WEBAPP_URL}/environments/${environmentId}/settings/notifications`}
|
||||
className="text-black underline">
|
||||
className="text-black underline"
|
||||
href={`${WEBAPP_URL}/environments/${environmentId}/settings/notifications`}>
|
||||
please turn them off
|
||||
</Link>{" "}
|
||||
in your settings 🙏
|
||||
@@ -29,4 +27,4 @@ export const NotificationFooter = ({ environmentId }: NotificatonFooterProps) =>
|
||||
</Container>
|
||||
</Tailwind>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -9,32 +9,27 @@ interface NotificationHeaderProps {
|
||||
endYear: number;
|
||||
}
|
||||
|
||||
export const NotificationHeader = ({
|
||||
export function NotificationHeader({
|
||||
productName,
|
||||
startDate,
|
||||
endDate,
|
||||
startYear,
|
||||
endYear,
|
||||
}: NotificationHeaderProps) => {
|
||||
const getNotificationHeaderimePeriod = (
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
startYear: number,
|
||||
endYear: number
|
||||
) => {
|
||||
if (startYear == endYear) {
|
||||
}: NotificationHeaderProps) {
|
||||
const getNotificationHeaderimePeriod = () => {
|
||||
if (startYear === endYear) {
|
||||
return (
|
||||
<Text className="m-0 text-right">
|
||||
{startDate} - {endDate} {endYear}
|
||||
</Text>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Text className="m-0 text-right">
|
||||
{startDate} {startYear} - {endDate} {endYear}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text className="m-0 text-right">
|
||||
{startDate} {startYear} - {endDate} {endYear}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Container>
|
||||
@@ -44,9 +39,9 @@ export const NotificationHeader = ({
|
||||
</div>
|
||||
<div className="float-right">
|
||||
<Text className="m-0 text-right font-semibold">Weekly Report for {productName}</Text>
|
||||
{getNotificationHeaderimePeriod(startDate, endDate, startYear, endYear)}
|
||||
{getNotificationHeaderimePeriod()}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Column, Container, Row, Section, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { TWeeklySummaryInsights } from "@formbricks/types/weeklySummary";
|
||||
import type { TWeeklySummaryInsights } from "@formbricks/types/weeklySummary";
|
||||
|
||||
interface NotificationInsightProps {
|
||||
insights: TWeeklySummaryInsights;
|
||||
}
|
||||
|
||||
export const NotificationInsight = ({ insights }: NotificationInsightProps) => {
|
||||
export function NotificationInsight({ insights }: NotificationInsightProps) {
|
||||
return (
|
||||
<Container>
|
||||
<Section className="my-4 rounded-md bg-slate-100">
|
||||
@@ -40,4 +39,4 @@ export const NotificationInsight = ({ insights }: NotificationInsightProps) => {
|
||||
</Section>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import React from "react";
|
||||
|
||||
import { TWeeklySummaryNotificationResponse } from "@formbricks/types/weeklySummary";
|
||||
|
||||
import { LiveSurveyNotification } from "./LiveSurveyNotification";
|
||||
import { NotificationFooter } from "./NotificationFooter";
|
||||
import { NotificationHeader } from "./NotificationHeader";
|
||||
import { NotificationInsight } from "./NotificationInsight";
|
||||
import type { TWeeklySummaryNotificationResponse } from "@formbricks/types/weeklySummary";
|
||||
import { LiveSurveyNotification } from "./live-survey-notification";
|
||||
import { NotificationFooter } from "./notification-footer";
|
||||
import { NotificationHeader } from "./notification-header";
|
||||
import { NotificationInsight } from "./notification-insight";
|
||||
|
||||
interface WeeklySummaryNotificationEmailProps {
|
||||
notificationData: TWeeklySummaryNotificationResponse;
|
||||
@@ -15,28 +13,28 @@ interface WeeklySummaryNotificationEmailProps {
|
||||
endYear: number;
|
||||
}
|
||||
|
||||
export const WeeklySummaryNotificationEmail = ({
|
||||
export function WeeklySummaryNotificationEmail({
|
||||
notificationData,
|
||||
startDate,
|
||||
endDate,
|
||||
startYear,
|
||||
endYear,
|
||||
}: WeeklySummaryNotificationEmailProps) => {
|
||||
}: WeeklySummaryNotificationEmailProps) {
|
||||
return (
|
||||
<div>
|
||||
<NotificationHeader
|
||||
endDate={endDate}
|
||||
endYear={endYear}
|
||||
productName={notificationData.productName}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
startYear={startYear}
|
||||
endYear={endYear}
|
||||
/>
|
||||
<NotificationInsight insights={notificationData.insights} />
|
||||
<LiveSurveyNotification
|
||||
surveys={notificationData.surveys}
|
||||
environmentId={notificationData.environmentId}
|
||||
surveys={notificationData.surveys}
|
||||
/>
|
||||
<NotificationFooter environmentId={notificationData.environmentId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render } from "@react-email/render";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
import type SMTPTransport from "nodemailer/lib/smtp-transport";
|
||||
import {
|
||||
DEBUG,
|
||||
MAIL_FROM,
|
||||
@@ -13,26 +13,25 @@ import {
|
||||
} from "@formbricks/lib/constants";
|
||||
import { createInviteToken, createToken, createTokenForLinkSurvey } from "@formbricks/lib/jwt";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { TWeeklySummaryNotificationResponse } from "@formbricks/types/weeklySummary";
|
||||
import type { TResponse } from "@formbricks/types/responses";
|
||||
import type { TSurvey } from "@formbricks/types/surveys";
|
||||
import type { TWeeklySummaryNotificationResponse } from "@formbricks/types/weeklySummary";
|
||||
import { ForgotPasswordEmail } from "./components/auth/forgot-password-email";
|
||||
import { PasswordResetNotifyEmail } from "./components/auth/password-reset-notify-email";
|
||||
import { VerificationEmail } from "./components/auth/verification-email";
|
||||
import { EmailTemplate } from "./components/general/email-template";
|
||||
import { InviteAcceptedEmail } from "./components/invite/invite-accepted-email";
|
||||
import { InviteEmail } from "./components/invite/invite-email";
|
||||
import { OnboardingInviteEmail } from "./components/invite/onboarding-invite-email";
|
||||
import { EmbedSurveyPreviewEmail } from "./components/survey/embed-survey-preview-email";
|
||||
import { LinkSurveyEmail } from "./components/survey/link-survey-email";
|
||||
import { ResponseFinishedEmail } from "./components/survey/response-finished-email";
|
||||
import { NoLiveSurveyNotificationEmail } from "./components/weekly-summary/no-live-survey-notification-email";
|
||||
import { WeeklySummaryNotificationEmail } from "./components/weekly-summary/weekly-summary-notification-email";
|
||||
|
||||
import { ForgotPasswordEmail } from "./components/auth/ForgotPasswordEmail";
|
||||
import { PasswordResetNotifyEmail } from "./components/auth/PasswordResetNotifyEmail";
|
||||
import { VerificationEmail } from "./components/auth/VerificationEmail";
|
||||
import { EmailTemplate } from "./components/general/EmailTemplate";
|
||||
import { InviteAcceptedEmail } from "./components/invite/InviteAcceptedEmail";
|
||||
import { InviteEmail } from "./components/invite/InviteEmail";
|
||||
import { OnboardingInviteEmail } from "./components/invite/OnboardingInviteEmail";
|
||||
import { EmbedSurveyPreviewEmail } from "./components/survey/EmbedSurveyPreviewEmail";
|
||||
import { LinkSurveyEmail } from "./components/survey/LinkSurveyEmail";
|
||||
import { ResponseFinishedEmail } from "./components/survey/ResponseFinishedEmail";
|
||||
import { NoLiveSurveyNotificationEmail } from "./components/weekly-summary/NoLiveSurveyNotificationEmail";
|
||||
import { WeeklySummaryNotificationEmail } from "./components/weekly-summary/WeeklySummaryNotificationEmail";
|
||||
export const IS_SMTP_CONFIGURED = Boolean(SMTP_HOST && SMTP_PORT);
|
||||
|
||||
export const IS_SMTP_CONFIGURED: boolean = SMTP_HOST && SMTP_PORT ? true : false;
|
||||
|
||||
interface sendEmailData {
|
||||
interface SendEmailDataProps {
|
||||
to: string;
|
||||
replyTo?: string;
|
||||
subject: string;
|
||||
@@ -64,29 +63,26 @@ const getEmailSubject = (productName: string): string => {
|
||||
|
||||
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
export const sendEmail = async (emailData: sendEmailData) => {
|
||||
try {
|
||||
if (IS_SMTP_CONFIGURED) {
|
||||
let transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_SECURE_ENABLED, // true for 465, false for other ports
|
||||
auth: {
|
||||
user: SMTP_USER,
|
||||
pass: SMTP_PASSWORD,
|
||||
},
|
||||
logger: DEBUG,
|
||||
debug: DEBUG,
|
||||
});
|
||||
const emailDefaults = {
|
||||
from: `Formbricks <${MAIL_FROM || "noreply@formbricks.com"}>`,
|
||||
};
|
||||
await transporter.sendMail({ ...emailDefaults, ...emailData });
|
||||
} else {
|
||||
console.error(`Could not Email :: SMTP not configured :: ${emailData.subject}`);
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
export const sendEmail = async (emailData: SendEmailDataProps) => {
|
||||
if (IS_SMTP_CONFIGURED) {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_SECURE_ENABLED, // true for 465, false for other ports
|
||||
auth: {
|
||||
user: SMTP_USER,
|
||||
pass: SMTP_PASSWORD,
|
||||
},
|
||||
logger: DEBUG,
|
||||
debug: DEBUG,
|
||||
} as SMTPTransport.Options);
|
||||
const emailDefaults = {
|
||||
from: `Formbricks <${MAIL_FROM || "noreply@formbricks.com"}>`,
|
||||
};
|
||||
await transporter.sendMail({ ...emailDefaults, ...emailData });
|
||||
} else {
|
||||
// eslint-disable-next-line no-console -- necessary for logging email configuration errors
|
||||
console.error(`Could not Email :: SMTP not configured :: ${emailData.subject}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -202,8 +198,8 @@ export const sendEmbedSurveyPreviewEmail = async (
|
||||
environmentId: string
|
||||
) => {
|
||||
await sendEmail({
|
||||
to: to,
|
||||
subject: subject,
|
||||
to,
|
||||
subject,
|
||||
html: render(EmailTemplate({ content: EmbedSurveyPreviewEmail({ html, environmentId }) })),
|
||||
});
|
||||
};
|
||||
@@ -212,7 +208,7 @@ export const sendLinkSurveyToVerifiedEmail = async (data: LinkSurveyEmailData) =
|
||||
const surveyId = data.surveyId;
|
||||
const email = data.email;
|
||||
const surveyData = data.surveyData;
|
||||
const singleUseId = data.suId ?? null;
|
||||
const singleUseId = data.suId;
|
||||
const token = createTokenForLinkSurvey(surveyId, email);
|
||||
const getSurveyLink = () => {
|
||||
if (singleUseId) {
|
||||
|
||||
@@ -4,16 +4,22 @@
|
||||
"description": "Email package",
|
||||
"main": "./index.tsx",
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules dist"
|
||||
"clean": "rimraf .turbo node_modules dist",
|
||||
"lint": "eslint --ext .ts,.tsx --fix ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"@react-email/components": "^0.0.19",
|
||||
"@react-email/render": "^0.0.15",
|
||||
"lucide-react": "^0.379.0",
|
||||
"lucide-react": "^0.390.0",
|
||||
"nodemailer": "^6.9.13",
|
||||
"react-email": "^2.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/react": "18.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
13
packages/email/tsconfig.json
Normal file
13
packages/email/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "@formbricks/config-typescript/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["/*"]
|
||||
},
|
||||
"resolveJsonModule": true,
|
||||
"strictNullChecks": true
|
||||
},
|
||||
"include": [".", "../../packages/types/*.d.ts"],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { mockSegment } from "segment/tests/__mocks__/segment.mock";
|
||||
|
||||
import { mockSurveyLanguages } from "survey/tests/__mock__/survey.mock";
|
||||
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyCTAQuestion,
|
||||
@@ -13,7 +11,7 @@ import {
|
||||
TSurveyNPSQuestion,
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyPictureSelectionQuestion,
|
||||
TSurveyQuestionType,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyRatingQuestion,
|
||||
TSurveyThankYouCard,
|
||||
TSurveyWelcomeCard,
|
||||
@@ -34,7 +32,7 @@ export const mockWelcomeCard: TSurveyWelcomeCard = {
|
||||
|
||||
export const mockOpenTextQuestion: TSurveyOpenTextQuestion = {
|
||||
id: "lqht9sj5s6andjkmr9k1n54q",
|
||||
type: TSurveyQuestionType.OpenText,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: {
|
||||
default: "What would you like to know?",
|
||||
},
|
||||
@@ -51,7 +49,7 @@ export const mockOpenTextQuestion: TSurveyOpenTextQuestion = {
|
||||
|
||||
export const mockSingleSelectQuestion: TSurveyMultipleChoiceQuestion = {
|
||||
id: "mvqx8t90np6isb6oel9eamzc",
|
||||
type: TSurveyQuestionType.MultipleChoiceSingle,
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
choices: [
|
||||
{
|
||||
id: "r52sul8ag19upaicit0fyqzo",
|
||||
@@ -104,7 +102,7 @@ export const mockMultiSelectQuestion: TSurveyMultipleChoiceQuestion = {
|
||||
],
|
||||
shuffleOption: "none",
|
||||
id: "cpydxgsmjg8q9iwfa8wj4ida",
|
||||
type: TSurveyQuestionType.MultipleChoiceMulti,
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
isDraft: true,
|
||||
};
|
||||
|
||||
@@ -128,7 +126,7 @@ export const mockPictureSelectQuestion: TSurveyPictureSelectionQuestion = {
|
||||
},
|
||||
],
|
||||
id: "a8monbe8hq0mivh3irfhd3i5",
|
||||
type: TSurveyQuestionType.PictureSelection,
|
||||
type: TSurveyQuestionTypeEnum.PictureSelection,
|
||||
isDraft: true,
|
||||
};
|
||||
|
||||
@@ -149,7 +147,7 @@ export const mockRatingQuestion: TSurveyRatingQuestion = {
|
||||
default: "Very good",
|
||||
},
|
||||
id: "waldsboahjtgqhg5p18d1awz",
|
||||
type: TSurveyQuestionType.Rating,
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
isDraft: true,
|
||||
};
|
||||
|
||||
@@ -165,7 +163,7 @@ export const mockNpsQuestion: TSurveyNPSQuestion = {
|
||||
default: "Extremely likely",
|
||||
},
|
||||
id: "m9pemgdih2p4exvkmeeqq6jf",
|
||||
type: TSurveyQuestionType.NPS,
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
isDraft: true,
|
||||
};
|
||||
|
||||
@@ -182,7 +180,7 @@ export const mockCtaQuestion: TSurveyCTAQuestion = {
|
||||
default: "Skip",
|
||||
},
|
||||
id: "gwn15urom4ffnhfimwbz3vgc",
|
||||
type: TSurveyQuestionType.CTA,
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
isDraft: true,
|
||||
};
|
||||
|
||||
@@ -195,7 +193,7 @@ export const mockConsentQuestion: TSurveyConsentQuestion = {
|
||||
default: "I agree to the terms and conditions",
|
||||
},
|
||||
id: "av561aoif3i2hjlsl6krnsfm",
|
||||
type: TSurveyQuestionType.Consent,
|
||||
type: TSurveyQuestionTypeEnum.Consent,
|
||||
isDraft: true,
|
||||
};
|
||||
|
||||
@@ -206,7 +204,7 @@ export const mockDateQuestion: TSurveyDateQuestion = {
|
||||
},
|
||||
format: "M-d-y",
|
||||
id: "ts2f6v2oo9jfmfli9kk6lki9",
|
||||
type: TSurveyQuestionType.Date,
|
||||
type: TSurveyQuestionTypeEnum.Date,
|
||||
isDraft: true,
|
||||
};
|
||||
|
||||
@@ -217,7 +215,7 @@ export const mockFileUploadQuestion: TSurveyFileUploadQuestion = {
|
||||
},
|
||||
allowMultipleFiles: false,
|
||||
id: "ozzxo2jj1s6mj56c79q8pbef",
|
||||
type: TSurveyQuestionType.FileUpload,
|
||||
type: TSurveyQuestionTypeEnum.FileUpload,
|
||||
isDraft: true,
|
||||
};
|
||||
|
||||
@@ -231,7 +229,7 @@ export const mockCalQuestion: TSurveyCalQuestion = {
|
||||
},
|
||||
calUserName: "rick/get-rick-rolled",
|
||||
id: "o3bnux6p42u9ew9d02l14r26",
|
||||
type: TSurveyQuestionType.Cal,
|
||||
type: TSurveyQuestionTypeEnum.Cal,
|
||||
isDraft: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { isAfter, isBefore, isSameDay } from "date-fns";
|
||||
|
||||
import { TDisplay } from "@formbricks/types/displays";
|
||||
import { TResponse, TResponseFilterCriteria, TResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import { TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
|
||||
import { responseNoteSelect } from "../../../responseNote/service";
|
||||
import { responseSelection } from "../../service";
|
||||
import { constantsForTests } from "../constants";
|
||||
@@ -381,7 +379,7 @@ export const mockSurveySummaryOutput = {
|
||||
id: "ars2tjk8hsi8oqk1uac00mo8",
|
||||
inputType: "text",
|
||||
required: false,
|
||||
type: TSurveyQuestionType.OpenText,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
},
|
||||
responseCount: 0,
|
||||
samples: [],
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import "server-only";
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import {
|
||||
TResponse,
|
||||
TResponseFilterCriteria,
|
||||
@@ -22,10 +20,9 @@ import {
|
||||
TSurveyQuestionSummaryOpenText,
|
||||
TSurveyQuestionSummaryPictureSelection,
|
||||
TSurveyQuestionSummaryRating,
|
||||
TSurveyQuestionType,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveySummary,
|
||||
} from "@formbricks/types/surveys";
|
||||
|
||||
import { getLocalizedValue } from "../i18n/utils";
|
||||
import { processResponseData } from "../responses";
|
||||
import { getTodaysDateTimeFormatted } from "../time";
|
||||
@@ -701,7 +698,7 @@ export const getQuestionWiseSummary = (
|
||||
|
||||
survey.questions.forEach((question, idx) => {
|
||||
switch (question.type) {
|
||||
case TSurveyQuestionType.OpenText: {
|
||||
case TSurveyQuestionTypeEnum.OpenText: {
|
||||
let values: TSurveyQuestionSummaryOpenText["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
@@ -726,8 +723,8 @@ export const getQuestionWiseSummary = (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionType.MultipleChoiceSingle:
|
||||
case TSurveyQuestionType.MultipleChoiceMulti: {
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti: {
|
||||
let values: TSurveyQuestionSummaryMultipleChoice["choices"] = [];
|
||||
// check last choice is others or not
|
||||
const lastChoice = question.choices[question.choices.length - 1];
|
||||
@@ -808,7 +805,7 @@ export const getQuestionWiseSummary = (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionType.PictureSelection: {
|
||||
case TSurveyQuestionTypeEnum.PictureSelection: {
|
||||
let values: TSurveyQuestionSummaryPictureSelection["choices"] = [];
|
||||
const choiceCountMap: Record<string, number> = {};
|
||||
|
||||
@@ -849,7 +846,7 @@ export const getQuestionWiseSummary = (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionType.Rating: {
|
||||
case TSurveyQuestionTypeEnum.Rating: {
|
||||
let values: TSurveyQuestionSummaryRating["choices"] = [];
|
||||
const choiceCountMap: Record<number, number> = {};
|
||||
const range = question.range;
|
||||
@@ -899,7 +896,7 @@ export const getQuestionWiseSummary = (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionType.NPS: {
|
||||
case TSurveyQuestionTypeEnum.NPS: {
|
||||
const data = {
|
||||
promoters: 0,
|
||||
passives: 0,
|
||||
@@ -956,7 +953,7 @@ export const getQuestionWiseSummary = (
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionType.CTA: {
|
||||
case TSurveyQuestionTypeEnum.CTA: {
|
||||
const data = {
|
||||
clicked: 0,
|
||||
dismissed: 0,
|
||||
@@ -988,7 +985,7 @@ export const getQuestionWiseSummary = (
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionType.Consent: {
|
||||
case TSurveyQuestionTypeEnum.Consent: {
|
||||
const data = {
|
||||
accepted: 0,
|
||||
dismissed: 0,
|
||||
@@ -1023,7 +1020,7 @@ export const getQuestionWiseSummary = (
|
||||
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionType.Date: {
|
||||
case TSurveyQuestionTypeEnum.Date: {
|
||||
let values: TSurveyQuestionSummaryDate["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
@@ -1048,7 +1045,7 @@ export const getQuestionWiseSummary = (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionType.FileUpload: {
|
||||
case TSurveyQuestionTypeEnum.FileUpload: {
|
||||
let values: TSurveyQuestionSummaryFileUpload["files"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
@@ -1073,7 +1070,7 @@ export const getQuestionWiseSummary = (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionType.Cal: {
|
||||
case TSurveyQuestionTypeEnum.Cal: {
|
||||
const data = {
|
||||
booked: 0,
|
||||
skipped: 0,
|
||||
@@ -1106,7 +1103,7 @@ export const getQuestionWiseSummary = (
|
||||
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionType.Matrix: {
|
||||
case TSurveyQuestionTypeEnum.Matrix: {
|
||||
const rows = question.rows.map((row) => getLocalizedValue(row, "default"));
|
||||
const columns = question.columns.map((column) => getLocalizedValue(column, "default"));
|
||||
let totalResponseCount = 0;
|
||||
@@ -1163,7 +1160,7 @@ export const getQuestionWiseSummary = (
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionType.Address: {
|
||||
case TSurveyQuestionTypeEnum.Address: {
|
||||
let values: TSurveyQuestionSummaryAddress["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZString } from "@formbricks/types/common";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
@@ -23,7 +22,6 @@ import {
|
||||
ZSegmentFilters,
|
||||
ZSegmentUpdateInput,
|
||||
} from "@formbricks/types/segment";
|
||||
|
||||
import {
|
||||
getActionCountInLastMonth,
|
||||
getActionCountInLastQuarter,
|
||||
@@ -50,7 +48,7 @@ type PrismaSegment = Prisma.SegmentGetPayload<{
|
||||
};
|
||||
}>;
|
||||
|
||||
export const selectSegment: Prisma.SegmentDefaultArgs["select"] = {
|
||||
export const selectSegment = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
@@ -62,6 +60,8 @@ export const selectSegment: Prisma.SegmentDefaultArgs["select"] = {
|
||||
surveys: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user