chore: fix eslint issues in ee & email packages (#2742)

This commit is contained in:
Matti Nannt
2024-06-07 15:05:46 +02:00
committed by GitHub
parent a269da4e1c
commit c73d4e82b5
112 changed files with 2173 additions and 1946 deletions

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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 [];

View File

@@ -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">

View File

@@ -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

View File

@@ -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>
);

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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() === "") ||

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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];

View File

@@ -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 = [

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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}

View File

@@ -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";

View File

@@ -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 (

View File

@@ -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:

View File

@@ -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;

View File

@@ -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,

View File

@@ -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) => {

View File

@@ -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;
};

View File

@@ -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 &&

View File

@@ -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;

View File

@@ -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",

View File

@@ -44,8 +44,7 @@
},
"lint-staged": {
"(apps|packages)/**/*.{js,ts,jsx,tsx}": [
"prettier --write",
"eslint --fix"
"prettier --write"
],
"*.json": [
"prettier --write"

View File

@@ -16,6 +16,6 @@ module.exports = {
"^~/(.*)$",
"^[./]",
],
importOrderSeparation: true,
importOrderSeparation: false,
importOrderSortSpecifiers: true,
};

View File

@@ -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>
);
};
}

View File

@@ -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>
);
};
}

View File

@@ -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>
</>
);
};
}

View 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>
);
}

View File

@@ -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>;
}
};
}

View 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>
);
}

View File

@@ -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,

View File

@@ -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>
);
};

View File

@@ -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>
</>
);
};

View File

@@ -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);

View File

@@ -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);

View File

@@ -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"

View File

@@ -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);

View File

@@ -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,

View File

@@ -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`,
};
}
};

View File

@@ -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";
}

View File

@@ -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}` };
}
};

View File

@@ -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;
};

View File

@@ -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>
);
};
}

View File

@@ -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 hasnt 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>
);

View File

@@ -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>
);
};
}

View File

@@ -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>
);
};
}

View File

@@ -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>
);
};
}

View File

@@ -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>
);
};
}

View File

@@ -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>
);
};
}

View File

@@ -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>
);
};
}

View File

@@ -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>

View File

@@ -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>
);
};
}

View File

@@ -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,

View File

@@ -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"
}
}

View File

@@ -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
)}
/>
);
};
}

View File

@@ -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" />;
}

View File

@@ -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>
);
};
}

View File

@@ -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);

View File

@@ -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"]
}

View File

@@ -1,3 +1,3 @@
module.exports = {
extends: ["@formbricks/eslint-config/legacy-next.js"],
extends: ["@formbricks/eslint-config/react.js"],
};

View File

@@ -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&apos;t request this, please ignore this email.</Text>
<EmailFooter />
</Container>
);
};
}

View File

@@ -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>
);
};
}

View File

@@ -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>
);
};
}

View File

@@ -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>
);

View File

@@ -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>
);
};
}

View File

@@ -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>
);
};
}

View 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>
);
}

View File

@@ -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>
);
};
}

View File

@@ -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>
);
};
}

View File

@@ -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>
);
};
}

View File

@@ -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&apos;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>
);
};
}

View File

@@ -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>
);
};
}

View File

@@ -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>
);
};
}

View File

@@ -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>
);
};
}

View File

@@ -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">Dont 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>
);
};
}

View File

@@ -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>
)}

View File

@@ -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>
);
};
}

View File

@@ -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>
);
};
}

View File

@@ -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>
);
};
}

View File

@@ -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>
);
};
}

View File

@@ -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>
);
};
}

View File

@@ -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) {

View File

@@ -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"
}
}

View 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"]
}

View File

@@ -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,
};

View File

@@ -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: [],

View File

@@ -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];

View File

@@ -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