mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-04 10:30:00 -06:00
Merge branch 'feat-advanced-logic-editor' of https://github.com/formbricks/formbricks into feat-advanced-logic-editor
This commit is contained in:
@@ -79,28 +79,6 @@ Promise<{ id: string }, NetworkError | Error>
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
- Update Display
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Update Display">
|
||||
|
||||
```javascript {{ title: 'Update Display Method Call'}}
|
||||
await api.client.display.update(
|
||||
displayId: "<your-display-id>",
|
||||
{
|
||||
userId: "<your-user-id>", // optional
|
||||
responseId: "<your-response-id>", // optional
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
```javascript {{ title: 'Update Display Method Return Type' }}
|
||||
Promise<{ }, NetworkError | Error]>
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
## Responses
|
||||
|
||||
- Create Response
|
||||
@@ -173,29 +151,6 @@ Promise<{ }, NetworkError | Error]>
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
## Action
|
||||
|
||||
- Create Action:
|
||||
|
||||
<Note> An environment cannot have 2 actions with the same name. </Note>
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Create Action">
|
||||
|
||||
```javascript {{ title: 'Create Action Method Call'}}
|
||||
await api.client.action.create({
|
||||
name: "<your-action-name>", // required
|
||||
userId: "<your-user-id>", // required
|
||||
});
|
||||
```
|
||||
|
||||
```javascript {{ title: 'Create Action Method Return Type' }}
|
||||
Promise<{ }, NetworkError | Error]>
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
## Attribute
|
||||
|
||||
- Update Attribute
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
||||
import { getCustomHeadline } from "@/app/(app)/(onboarding)/lib/utils";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
@@ -26,12 +25,9 @@ const Page = async ({ params }: ConnectPageProps) => {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
const channel = product.config.channel;
|
||||
const industry = product.config.industry;
|
||||
const channel = product.config.channel || null;
|
||||
const industry = product.config.industry || null;
|
||||
|
||||
if (!channel || !industry) {
|
||||
return notFound();
|
||||
}
|
||||
const customHeadline = getCustomHeadline(channel, industry);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils";
|
||||
import { XMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates";
|
||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||
import { ActivityIcon, ShoppingCartIcon, SmileIcon, StarIcon, ThumbsUpIcon, UsersIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { createSurveyAction } from "@formbricks/ui/TemplateList/actions";
|
||||
|
||||
interface XMTemplateListProps {
|
||||
product: TProduct;
|
||||
user: TUser;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const XMTemplateList = ({ product, user, environmentId }: XMTemplateListProps) => {
|
||||
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const createSurvey = async (activeTemplate: TXMTemplate) => {
|
||||
const augmentedTemplate: TSurveyCreateInput = {
|
||||
...activeTemplate,
|
||||
type: "link",
|
||||
createdBy: user.id,
|
||||
};
|
||||
const createSurveyResponse = await createSurveyAction({
|
||||
environmentId: environmentId,
|
||||
surveyBody: augmentedTemplate,
|
||||
});
|
||||
|
||||
if (createSurveyResponse?.data) {
|
||||
router.push(`/environments/${environmentId}/surveys/${createSurveyResponse.data.id}/edit?mode=cx`);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createSurveyResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTemplateClick = (templateIdx) => {
|
||||
setActiveTemplateId(templateIdx);
|
||||
const template = XMTemplates[templateIdx];
|
||||
const newTemplate = replacePresetPlaceholders(template, product);
|
||||
createSurvey(newTemplate);
|
||||
};
|
||||
|
||||
const XMTemplateOptions = [
|
||||
{
|
||||
title: "NPS",
|
||||
description: "Implement proven best practices to understand WHY people buy.",
|
||||
icon: ShoppingCartIcon,
|
||||
onClick: () => handleTemplateClick(0),
|
||||
isLoading: activeTemplateId === 0,
|
||||
},
|
||||
{
|
||||
title: "5-Star Rating",
|
||||
description: "Universal feedback solution to gauge overall satisfaction.",
|
||||
icon: StarIcon,
|
||||
onClick: () => handleTemplateClick(1),
|
||||
isLoading: activeTemplateId === 1,
|
||||
},
|
||||
{
|
||||
title: "CSAT",
|
||||
description: "Implement best practices to measure customer satisfaction.",
|
||||
icon: ThumbsUpIcon,
|
||||
onClick: () => handleTemplateClick(2),
|
||||
isLoading: activeTemplateId === 2,
|
||||
},
|
||||
{
|
||||
title: "CES",
|
||||
description: "Leverage every touchpoint to understand ease of customer interaction.",
|
||||
icon: ActivityIcon,
|
||||
onClick: () => handleTemplateClick(3),
|
||||
isLoading: activeTemplateId === 3,
|
||||
},
|
||||
{
|
||||
title: "Smileys",
|
||||
description: "Use visual indicators to capture feedback across customer touchpoints.",
|
||||
icon: SmileIcon,
|
||||
onClick: () => handleTemplateClick(4),
|
||||
isLoading: activeTemplateId === 4,
|
||||
},
|
||||
{
|
||||
title: "eNPS",
|
||||
description: "Universal feedback to understand employee engagement and satisfaction.",
|
||||
icon: UsersIcon,
|
||||
onClick: () => handleTemplateClick(5),
|
||||
isLoading: activeTemplateId === 5,
|
||||
},
|
||||
];
|
||||
|
||||
return <OnboardingOptionsContainer options={XMTemplateOptions} />;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { replaceQuestionPresetPlaceholders } from "@formbricks/lib/utils/templates";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
|
||||
// replace all occurences of productName with the actual product name in the current template
|
||||
export const replacePresetPlaceholders = (template: TXMTemplate, product: TProduct) => {
|
||||
const survey = structuredClone(template);
|
||||
survey.name = survey.name.replace("{{productName}}", product.name);
|
||||
survey.questions = survey.questions.map((question) => {
|
||||
return replaceQuestionPresetPlaceholders(question, product);
|
||||
});
|
||||
return { ...template, ...survey };
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { getDefaultEndingCard } from "@formbricks/lib/templates";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
|
||||
export const XMSurveyDefault: TXMTemplate = {
|
||||
name: "",
|
||||
endings: [getDefaultEndingCard([])],
|
||||
questions: [],
|
||||
styling: {
|
||||
overwriteThemeStyling: true,
|
||||
},
|
||||
};
|
||||
|
||||
const NPSSurvey: TXMTemplate = {
|
||||
...XMSurveyDefault,
|
||||
name: "NPS Survey",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: { default: "How likely are you to recommend {{productName}} to a friend or colleague?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Not at all likely" },
|
||||
upperLabel: { default: "Extremely likely" },
|
||||
isColorCodingEnabled: true,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "To help us improve, can you describe the reason(s) for your rating?" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Any other comments, feedback, or concerns?" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const StarRatingSurvey: TXMTemplate = {
|
||||
...XMSurveyDefault,
|
||||
name: "{{productName}}'s Rating Survey",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
logic: [{ value: 3, condition: "lessEqual", destination: "tk9wpw2gxgb8fa6pbpp3qq5l" }],
|
||||
range: 5,
|
||||
scale: "number",
|
||||
headline: { default: "How do you like {{productName}}?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Extremely dissatisfied" },
|
||||
upperLabel: { default: "Extremely satisfied" },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
html: { default: '<p class="fb-editor-paragraph" dir="ltr"><span>This helps us a lot.</span></p>' },
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
logic: [{ condition: "clicked", destination: XMSurveyDefault.endings[0].id }],
|
||||
headline: { default: "Happy to hear 🙏 Please write a review for us!" },
|
||||
required: true,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonLabel: { default: "Write review" },
|
||||
buttonExternal: true,
|
||||
},
|
||||
{
|
||||
id: "tk9wpw2gxgb8fa6pbpp3qq5l",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Sorry to hear! What is ONE thing we can do better?" },
|
||||
required: true,
|
||||
subheader: { default: "Help us improve your experience." },
|
||||
buttonLabel: { default: "Send" },
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const CSATSurvey: TXMTemplate = {
|
||||
...XMSurveyDefault,
|
||||
name: "{{productName}} CSAT",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
logic: [{ value: 3, condition: "lessEqual", destination: "vyo4mkw4ln95ts4ya7qp2tth" }],
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
headline: { default: "How satisfied are you with your {{productName}} experience?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Extremely dissatisfied" },
|
||||
upperLabel: { default: "Extremely satisfied" },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [{ condition: "submitted", destination: XMSurveyDefault.endings[0].id }],
|
||||
headline: { default: "Lovely! Is there anything we can do to improve your experience?" },
|
||||
required: false,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
{
|
||||
id: "vyo4mkw4ln95ts4ya7qp2tth",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Ugh, sorry! Is there anything we can do to improve your experience?" },
|
||||
required: false,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const CESSurvey: TXMTemplate = {
|
||||
...XMSurveyDefault,
|
||||
name: "CES Survey",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
range: 5,
|
||||
scale: "number",
|
||||
headline: { default: "{{productName}} makes it easy for me to [ADD GOAL]" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Disagree strongly" },
|
||||
upperLabel: { default: "Agree strongly" },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Thanks! How could we make it easier for you to [ADD GOAL]?" },
|
||||
required: true,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const SmileysRatingSurvey: TXMTemplate = {
|
||||
...XMSurveyDefault,
|
||||
name: "Smileys Survey",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
logic: [{ value: 3, condition: "lessEqual", destination: "tk9wpw2gxgb8fa6pbpp3qq5l" }],
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
headline: { default: "How do you like {{productName}}?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Not good" },
|
||||
upperLabel: { default: "Very satisfied" },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
html: { default: '<p class="fb-editor-paragraph" dir="ltr"><span>This helps us a lot.</span></p>' },
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
logic: [{ condition: "clicked", destination: XMSurveyDefault.endings[0].id }],
|
||||
headline: { default: "Happy to hear 🙏 Please write a review for us!" },
|
||||
required: true,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonLabel: { default: "Write review" },
|
||||
buttonExternal: true,
|
||||
},
|
||||
{
|
||||
id: "tk9wpw2gxgb8fa6pbpp3qq5l",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Sorry to hear! What is ONE thing we can do better?" },
|
||||
required: true,
|
||||
subheader: { default: "Help us improve your experience." },
|
||||
buttonLabel: { default: "Send" },
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eNPSSurvey: TXMTemplate = {
|
||||
...XMSurveyDefault,
|
||||
name: "eNPS Survey",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: {
|
||||
default: "How likely are you to recommend working at this company to a friend or colleague?",
|
||||
},
|
||||
required: false,
|
||||
lowerLabel: { default: "Not at all likely" },
|
||||
upperLabel: { default: "Extremely likely" },
|
||||
isColorCodingEnabled: true,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "To help us improve, can you describe the reason(s) for your rating?" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Any other comments, feedback, or concerns?" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const XMTemplates: TXMTemplate[] = [
|
||||
NPSSurvey,
|
||||
StarRatingSurvey,
|
||||
CSATSurvey,
|
||||
CESSurvey,
|
||||
SmileysRatingSurvey,
|
||||
eNPSSurvey,
|
||||
];
|
||||
@@ -0,0 +1,60 @@
|
||||
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils";
|
||||
import { getProductByEnvironmentId, getProducts } from "@formbricks/lib/product/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Header } from "@formbricks/ui/Header";
|
||||
|
||||
interface XMTemplatePageProps {
|
||||
params: {
|
||||
environmentId: string;
|
||||
};
|
||||
}
|
||||
|
||||
const Page = async ({ params }: XMTemplatePageProps) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||
|
||||
const product = await getProductByEnvironmentId(environment.id);
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
const products = await getProducts(organizationId);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header title="What kind of feedback would you like to get?" />
|
||||
<XMTemplateList product={product} user={user} environmentId={environment.id} />
|
||||
{products.length >= 2 && (
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="minimal"
|
||||
href={`/environments/${environment.id}/surveys`}>
|
||||
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
|
||||
|
||||
export const getCustomHeadline = (channel: TProductConfigChannel, industry: TProductConfigIndustry) => {
|
||||
export const getCustomHeadline = (channel?: TProductConfigChannel, industry?: TProductConfigIndustry) => {
|
||||
const combinations = {
|
||||
"website+eCommerce": "web shop",
|
||||
"website+saas": "landing page",
|
||||
|
||||
@@ -17,14 +17,14 @@ const Page = async ({ params }: ChannelPageProps) => {
|
||||
description: "Run well-timed pop-up surveys.",
|
||||
icon: GlobeIcon,
|
||||
iconText: "Built for scale",
|
||||
href: `/organizations/${params.organizationId}/products/new/industry?channel=website`,
|
||||
href: `/organizations/${params.organizationId}/products/new/settings?channel=website`,
|
||||
},
|
||||
{
|
||||
title: "App with sign up",
|
||||
description: "Run highly-targeted micro-surveys.",
|
||||
icon: GlobeLockIcon,
|
||||
iconText: "Enrich user profiles",
|
||||
href: `/organizations/${params.organizationId}/products/new/industry?channel=app`,
|
||||
href: `/organizations/${params.organizationId}/products/new/settings?channel=app`,
|
||||
},
|
||||
{
|
||||
channel: "link",
|
||||
@@ -32,7 +32,7 @@ const Page = async ({ params }: ChannelPageProps) => {
|
||||
description: "Reach people anywhere online.",
|
||||
icon: LinkIcon,
|
||||
iconText: "Anywhere online",
|
||||
href: `/organizations/${params.organizationId}/products/new/industry?channel=link`,
|
||||
href: `/organizations/${params.organizationId}/products/new/settings?channel=link`,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||
import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
|
||||
import { getProducts } from "@formbricks/lib/product/service";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Header } from "@formbricks/ui/Header";
|
||||
|
||||
interface ModePageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
};
|
||||
}
|
||||
|
||||
const Page = async ({ params }: ModePageProps) => {
|
||||
const channelOptions = [
|
||||
{
|
||||
title: "Formbricks Surveys",
|
||||
description: "Multi-purpose survey platform for web, app and email surveys.",
|
||||
icon: ListTodoIcon,
|
||||
href: `/organizations/${params.organizationId}/products/new/channel`,
|
||||
},
|
||||
{
|
||||
title: "Formbricks CX",
|
||||
description: "Surveys and reports to understand what your customers need.",
|
||||
icon: HeartIcon,
|
||||
href: `/organizations/${params.organizationId}/products/new/settings?mode=cx`,
|
||||
},
|
||||
];
|
||||
|
||||
const products = await getProducts(params.organizationId);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header title="What are you here for?" />
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{products.length >= 1 && (
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="minimal"
|
||||
href={"/"}>
|
||||
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -13,6 +13,7 @@ import { PREVIEW_SURVEY } from "@formbricks/lib/styling/constants";
|
||||
import {
|
||||
TProductConfigChannel,
|
||||
TProductConfigIndustry,
|
||||
TProductMode,
|
||||
TProductUpdateInput,
|
||||
ZProductUpdateInput,
|
||||
} from "@formbricks/types/product";
|
||||
@@ -32,6 +33,7 @@ import { SurveyInline } from "@formbricks/ui/Survey";
|
||||
|
||||
interface ProductSettingsProps {
|
||||
organizationId: string;
|
||||
productMode: TProductMode;
|
||||
channel: TProductConfigChannel;
|
||||
industry: TProductConfigIndustry;
|
||||
defaultBrandColor: string;
|
||||
@@ -39,6 +41,7 @@ interface ProductSettingsProps {
|
||||
|
||||
export const ProductSettings = ({
|
||||
organizationId,
|
||||
productMode,
|
||||
channel,
|
||||
industry,
|
||||
defaultBrandColor,
|
||||
@@ -68,10 +71,12 @@ export const ProductSettings = ({
|
||||
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||
}
|
||||
}
|
||||
if (channel !== "link") {
|
||||
if (channel === "app" || channel === "website") {
|
||||
router.push(`/environments/${productionEnvironment?.id}/connect`);
|
||||
} else {
|
||||
} else if (channel === "link") {
|
||||
router.push(`/environments/${productionEnvironment?.id}/surveys`);
|
||||
} else if (productMode === "cx") {
|
||||
router.push(`/environments/${productionEnvironment?.id}/xm-templates`);
|
||||
}
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createProductResponse);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { getCustomHeadline } from "@/app/(app)/(onboarding)/lib/utils";
|
||||
import { ProductSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
|
||||
import { getProducts } from "@formbricks/lib/product/service";
|
||||
import { startsWithVowel } from "@formbricks/lib/utils/strings";
|
||||
import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
|
||||
import { TProductConfigChannel, TProductConfigIndustry, TProductMode } from "@formbricks/types/product";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Header } from "@formbricks/ui/Header";
|
||||
|
||||
@@ -16,19 +15,21 @@ interface ProductSettingsPageProps {
|
||||
searchParams: {
|
||||
channel?: TProductConfigChannel;
|
||||
industry?: TProductConfigIndustry;
|
||||
mode?: TProductMode;
|
||||
};
|
||||
}
|
||||
|
||||
const Page = async ({ params, searchParams }: ProductSettingsPageProps) => {
|
||||
const channel = searchParams.channel;
|
||||
const industry = searchParams.industry;
|
||||
if (!channel || !industry) return notFound();
|
||||
const channel = searchParams.channel || null;
|
||||
const industry = searchParams.industry || null;
|
||||
const mode = searchParams.mode || "surveys";
|
||||
|
||||
const customHeadline = getCustomHeadline(channel, industry);
|
||||
const products = await getProducts(params.organizationId);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
{channel === "link" ? (
|
||||
{channel === "link" || mode === "cx" ? (
|
||||
<Header
|
||||
title="Match your brand, get 2x more responses."
|
||||
subtitle="When people recognize your brand, they are much more likely to start and complete responses."
|
||||
@@ -41,6 +42,7 @@ const Page = async ({ params, searchParams }: ProductSettingsPageProps) => {
|
||||
)}
|
||||
<ProductSettings
|
||||
organizationId={params.organizationId}
|
||||
productMode={mode}
|
||||
channel={channel}
|
||||
industry={industry}
|
||||
defaultBrandColor={DEFAULT_BRAND_COLOR}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LucideProps } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ForwardRefExoticComponent, RefAttributes } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { OptionCard } from "@formbricks/ui/OptionCard";
|
||||
|
||||
interface OnboardingOptionsContainerProps {
|
||||
@@ -8,34 +9,51 @@ interface OnboardingOptionsContainerProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: ForwardRefExoticComponent<Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>>;
|
||||
iconText: string;
|
||||
href: string;
|
||||
iconText?: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
isLoading?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => {
|
||||
const getOptionCard = (option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<OptionCard
|
||||
size="md"
|
||||
key={option.title}
|
||||
title={option.title}
|
||||
onSelect={option.onClick}
|
||||
description={option.description}
|
||||
loading={option.isLoading || false}>
|
||||
<div className="flex flex-col items-center">
|
||||
<Icon className="h-16 w-16 text-slate-600" strokeWidth={0.5} />
|
||||
{option.iconText && (
|
||||
<p className="mt-4 w-fit rounded-xl bg-slate-200 px-4 text-sm text-slate-700">
|
||||
{option.iconText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</OptionCard>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid w-5/6 grid-cols-3 gap-8 text-center lg:w-2/3">
|
||||
{options.map((option, index) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<Link href={option.href}>
|
||||
<OptionCard
|
||||
size="md"
|
||||
key={index}
|
||||
title={option.title}
|
||||
description={option.description}
|
||||
loading={false}>
|
||||
<div className="flex flex-col items-center">
|
||||
<Icon className="h-16 w-16 text-slate-600" strokeWidth={0.5} />
|
||||
<p className="mt-4 w-fit rounded-xl bg-slate-200 px-4 text-sm text-slate-700">
|
||||
{option.iconText}
|
||||
</p>
|
||||
</div>
|
||||
</OptionCard>
|
||||
<div
|
||||
className={cn({
|
||||
"grid w-5/6 grid-cols-3 gap-8 text-center lg:w-2/3": options.length >= 3,
|
||||
"flex justify-center gap-8": options.length < 3,
|
||||
})}>
|
||||
{options.map((option) =>
|
||||
option.href ? (
|
||||
<Link key={option.title} href={option.href}>
|
||||
{getOptionCard(option)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
) : (
|
||||
getOptionCard(option)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { getQuestionDefaults, questionTypes, universalQuestionPresets } from "@/app/lib/questions";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import {
|
||||
CXQuestionTypes,
|
||||
getQuestionDefaults,
|
||||
questionTypes,
|
||||
universalQuestionPresets,
|
||||
} from "@formbricks/lib/utils/questions";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
|
||||
interface AddQuestionButtonProps {
|
||||
addQuestion: (question: any) => void;
|
||||
product: TProduct;
|
||||
isCxMode: boolean;
|
||||
}
|
||||
|
||||
export const AddQuestionButton = ({ addQuestion, product }: AddQuestionButtonProps) => {
|
||||
export const AddQuestionButton = ({ addQuestion, product, isCxMode }: AddQuestionButtonProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const availableQuestionTypes = isCxMode ? CXQuestionTypes : questionTypes;
|
||||
|
||||
return (
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
@@ -37,7 +45,7 @@ export const AddQuestionButton = ({ addQuestion, product }: AddQuestionButtonPro
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="justify-left flex flex-col">
|
||||
{/* <hr className="py-1 text-slate-600" /> */}
|
||||
{questionTypes.map((questionType) => (
|
||||
{availableQuestionTypes.map((questionType) => (
|
||||
<button
|
||||
type="button"
|
||||
key={questionType.id}
|
||||
|
||||
@@ -32,11 +32,6 @@ interface EditEndingCardProps {
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
const endingCardTypes = [
|
||||
{ value: "endScreen", label: "Ending card" },
|
||||
{ value: "redirectToUrl", label: "Redirect to Url" },
|
||||
];
|
||||
|
||||
export const EditEndingCard = ({
|
||||
localSurvey,
|
||||
endingCardIndex,
|
||||
@@ -52,9 +47,16 @@ export const EditEndingCard = ({
|
||||
isFormbricksCloud,
|
||||
}: EditEndingCardProps) => {
|
||||
const endingCard = localSurvey.endings[endingCardIndex];
|
||||
|
||||
const isRedirectToUrlDisabled = isFormbricksCloud
|
||||
? plan === "free" && endingCard.type !== "redirectToUrl"
|
||||
: false;
|
||||
|
||||
const endingCardTypes = [
|
||||
{ value: "endScreen", label: "Ending card" },
|
||||
{ value: "redirectToUrl", label: "Redirect to Url", disabled: isRedirectToUrlDisabled },
|
||||
];
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: endingCard.id,
|
||||
});
|
||||
@@ -204,14 +206,16 @@ export const EditEndingCard = ({
|
||||
<OptionsSwitch
|
||||
options={endingCardTypes}
|
||||
currentOption={endingCard.type}
|
||||
handleOptionChange={() => {
|
||||
if (endingCard.type === "endScreen") {
|
||||
updateSurvey({ type: "redirectToUrl" });
|
||||
} else {
|
||||
updateSurvey({ type: "endScreen" });
|
||||
handleOptionChange={(newType) => {
|
||||
const selectedOption = endingCardTypes.find((option) => option.value === newType);
|
||||
if (!selectedOption?.disabled) {
|
||||
if (newType === "redirectToUrl") {
|
||||
updateSurvey({ type: "redirectToUrl" });
|
||||
} else {
|
||||
updateSurvey({ type: "endScreen" });
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={isRedirectToUrlDisabled}
|
||||
/>
|
||||
</TooltipRenderer>
|
||||
{endingCard.type === "endScreen" && (
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { QUESTIONS_ICON_MAP, QUESTIONS_NAME_MAP, getQuestionDefaults } from "@/app/lib/questions";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import {
|
||||
CX_QUESTIONS_NAME_MAP,
|
||||
QUESTIONS_ICON_MAP,
|
||||
QUESTIONS_NAME_MAP,
|
||||
getQuestionDefaults,
|
||||
} from "@formbricks/lib/utils/questions";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import {
|
||||
TSurvey,
|
||||
@@ -37,6 +42,7 @@ interface EditorCardMenuProps {
|
||||
addCard: (question: any, index?: number) => void;
|
||||
cardType: "question" | "ending";
|
||||
product?: TProduct;
|
||||
isCxMode?: boolean;
|
||||
}
|
||||
|
||||
export const EditorCardMenu = ({
|
||||
@@ -51,6 +57,7 @@ export const EditorCardMenu = ({
|
||||
updateCard,
|
||||
addCard,
|
||||
cardType,
|
||||
isCxMode = false,
|
||||
}: EditorCardMenuProps) => {
|
||||
const [logicWarningModal, setLogicWarningModal] = useState(false);
|
||||
const [changeToType, setChangeToType] = useState(
|
||||
@@ -61,6 +68,8 @@ export const EditorCardMenu = ({
|
||||
? survey.questions.length === 1
|
||||
: survey.type === "link" && survey.endings.length === 1;
|
||||
|
||||
const availableQuestionTypes = isCxMode ? CX_QUESTIONS_NAME_MAP : QUESTIONS_NAME_MAP;
|
||||
|
||||
const changeQuestionType = (type?: TSurveyQuestionTypeEnum) => {
|
||||
const parseResult = ZSurveyQuestion.safeParse(card);
|
||||
if (parseResult.success && type) {
|
||||
@@ -167,7 +176,7 @@ export const EditorCardMenu = ({
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent className="ml-2 border border-slate-200 text-slate-600 hover:text-slate-700">
|
||||
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
|
||||
{Object.entries(availableQuestionTypes).map(([type, name]) => {
|
||||
const parsedResult = ZSurveyQuestion.safeParse(card);
|
||||
if (parsedResult.success) {
|
||||
const question = parsedResult.data;
|
||||
@@ -212,7 +221,7 @@ export const EditorCardMenu = ({
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent className="ml-4 border border-slate-200">
|
||||
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
|
||||
{Object.entries(availableQuestionTypes).map(([type, name]) => {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
|
||||
@@ -231,7 +231,7 @@ export const HowToSendCard = ({
|
||||
You can also use Formbricks to run {promotedFeaturesString} surveys.{" "}
|
||||
<Link
|
||||
target="_blank"
|
||||
href={`/organizations/${organizationId}/products/new/channel`}
|
||||
href={`/organizations/${organizationId}/products/new/mode`}
|
||||
className="font-medium underline decoration-slate-400 underline-offset-2">
|
||||
Create a new product
|
||||
</Link>{" "}
|
||||
|
||||
@@ -213,6 +213,7 @@ export const MultipleChoiceQuestionForm = ({
|
||||
<Label htmlFor="choices">Options*</Label>
|
||||
<div className="mt-2" id="choices">
|
||||
<DndContext
|
||||
id="multi-choice-choices"
|
||||
onDragEnd={(event) => {
|
||||
const { active, over } = event;
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import { RankingQuestionForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm";
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util";
|
||||
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 { QUESTIONS_ICON_MAP, getTSurveyQuestionTypeEnumName } from "@formbricks/lib/utils/questions";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
@@ -54,6 +54,7 @@ interface QuestionCardProps {
|
||||
attributeClasses: TAttributeClass[];
|
||||
addQuestion: (question: any, index?: number) => void;
|
||||
isFormbricksCloud: boolean;
|
||||
isCxMode: boolean;
|
||||
}
|
||||
|
||||
export const QuestionCard = ({
|
||||
@@ -74,6 +75,7 @@ export const QuestionCard = ({
|
||||
attributeClasses,
|
||||
addQuestion,
|
||||
isFormbricksCloud,
|
||||
isCxMode,
|
||||
}: QuestionCardProps) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: question.id,
|
||||
@@ -206,6 +208,7 @@ export const QuestionCard = ({
|
||||
updateCard={updateQuestion}
|
||||
addCard={addQuestion}
|
||||
cardType="question"
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ interface QuestionsDraggableProps {
|
||||
attributeClasses: TAttributeClass[];
|
||||
addQuestion: (question: any, index?: number) => void;
|
||||
isFormbricksCloud: boolean;
|
||||
isCxMode: boolean;
|
||||
}
|
||||
|
||||
export const QuestionsDroppable = ({
|
||||
@@ -38,6 +39,7 @@ export const QuestionsDroppable = ({
|
||||
attributeClasses,
|
||||
addQuestion,
|
||||
isFormbricksCloud,
|
||||
isCxMode,
|
||||
}: QuestionsDraggableProps) => {
|
||||
return (
|
||||
<div className="group mb-5 flex w-full flex-col gap-5">
|
||||
@@ -62,6 +64,7 @@ export const QuestionsDroppable = ({
|
||||
attributeClasses={attributeClasses}
|
||||
addQuestion={addQuestion}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
@@ -31,12 +31,14 @@ interface QuestionsAudienceTabsProps {
|
||||
activeId: TSurveyEditorTabs;
|
||||
setActiveId: React.Dispatch<React.SetStateAction<TSurveyEditorTabs>>;
|
||||
isStylingTabVisible?: boolean;
|
||||
isCxMode: boolean;
|
||||
}
|
||||
|
||||
export const QuestionsAudienceTabs = ({
|
||||
activeId,
|
||||
setActiveId,
|
||||
isStylingTabVisible,
|
||||
isCxMode,
|
||||
}: QuestionsAudienceTabsProps) => {
|
||||
const tabsComputed = useMemo(() => {
|
||||
if (isStylingTabVisible) {
|
||||
@@ -45,10 +47,13 @@ export const QuestionsAudienceTabs = ({
|
||||
return tabs.filter((tab) => tab.id !== "styling");
|
||||
}, [isStylingTabVisible]);
|
||||
|
||||
// Hide settings tab in CX mode
|
||||
let tabsToDisplay = isCxMode ? tabsComputed.filter((tab) => tab.id !== "settings") : tabsComputed;
|
||||
|
||||
return (
|
||||
<div className="fixed z-30 flex h-12 w-full items-center justify-center border-b bg-white md:w-1/2">
|
||||
<nav className="flex h-full items-center space-x-4" aria-label="Tabs">
|
||||
{tabsComputed.map((tab) => (
|
||||
{tabsToDisplay.map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.id}
|
||||
|
||||
@@ -58,6 +58,7 @@ interface QuestionsViewProps {
|
||||
isFormbricksCloud: boolean;
|
||||
attributeClasses: TAttributeClass[];
|
||||
plan: TOrganizationBillingPlan;
|
||||
isCxMode: boolean;
|
||||
}
|
||||
|
||||
export const QuestionsView = ({
|
||||
@@ -74,6 +75,7 @@ export const QuestionsView = ({
|
||||
isFormbricksCloud,
|
||||
attributeClasses,
|
||||
plan,
|
||||
isCxMode,
|
||||
}: QuestionsViewProps) => {
|
||||
const internalQuestionIdMap = useMemo(() => {
|
||||
return localSurvey.questions.reduce((acc, question) => {
|
||||
@@ -431,20 +433,26 @@ export const QuestionsView = ({
|
||||
|
||||
return (
|
||||
<div className="mt-12 w-full px-5 py-4">
|
||||
<div className="mb-5 flex w-full flex-col gap-5">
|
||||
<EditWelcomeCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes("start") : false}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
</div>
|
||||
{!isCxMode && (
|
||||
<div className="mb-5 flex w-full flex-col gap-5">
|
||||
<EditWelcomeCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes("start") : false}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DndContext sensors={sensors} onDragEnd={onQuestionCardDragEnd} collisionDetection={closestCorners}>
|
||||
<DndContext
|
||||
id="questions"
|
||||
sensors={sensors}
|
||||
onDragEnd={onQuestionCardDragEnd}
|
||||
collisionDetection={closestCorners}>
|
||||
<QuestionsDroppable
|
||||
localSurvey={localSurvey}
|
||||
product={product}
|
||||
@@ -461,13 +469,18 @@ export const QuestionsView = ({
|
||||
attributeClasses={attributeClasses}
|
||||
addQuestion={addQuestion}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
</DndContext>
|
||||
|
||||
<AddQuestionButton addQuestion={addQuestion} product={product} />
|
||||
<AddQuestionButton addQuestion={addQuestion} product={product} isCxMode={isCxMode} />
|
||||
<div className="mt-5 flex flex-col gap-5">
|
||||
<hr className="border-t border-dashed" />
|
||||
<DndContext sensors={sensors} onDragEnd={onEndingCardDragEnd} collisionDetection={closestCorners}>
|
||||
<DndContext
|
||||
id="endings"
|
||||
sensors={sensors}
|
||||
onDragEnd={onEndingCardDragEnd}
|
||||
collisionDetection={closestCorners}>
|
||||
<SortableContext items={localSurvey.endings} strategy={verticalListSortingStrategy}>
|
||||
{localSurvey.endings.map((ending, index) => {
|
||||
return (
|
||||
@@ -491,37 +504,41 @@ export const QuestionsView = ({
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<AddEndingCardButton
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
addEndingCard={addEndingCard}
|
||||
/>
|
||||
<hr />
|
||||
{!isCxMode && (
|
||||
<>
|
||||
<AddEndingCardButton
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
addEndingCard={addEndingCard}
|
||||
/>
|
||||
<hr />
|
||||
|
||||
<HiddenFieldsCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
/>
|
||||
<HiddenFieldsCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
/>
|
||||
|
||||
<SurveyVariablesCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
/>
|
||||
<SurveyVariablesCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
/>
|
||||
|
||||
<MultiLanguageCard
|
||||
localSurvey={localSurvey}
|
||||
product={product}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
isMultiLanguageAllowed={isMultiLanguageAllowed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
/>
|
||||
<MultiLanguageCard
|
||||
localSurvey={localSurvey}
|
||||
product={product}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
isMultiLanguageAllowed={isMultiLanguageAllowed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -167,6 +167,7 @@ export const RankingQuestionForm = ({
|
||||
<Label htmlFor="choices">Options*</Label>
|
||||
<div className="mt-2" id="choices">
|
||||
<DndContext
|
||||
id="ranking-choices"
|
||||
onDragEnd={(event) => {
|
||||
const { active, over } = event;
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ type StylingViewProps = {
|
||||
localStylingChanges: TSurveyStyling | null;
|
||||
setLocalStylingChanges: React.Dispatch<React.SetStateAction<TSurveyStyling | null>>;
|
||||
isUnsplashConfigured: boolean;
|
||||
isCxMode: boolean;
|
||||
};
|
||||
|
||||
export const StylingView = ({
|
||||
@@ -47,6 +48,7 @@ export const StylingView = ({
|
||||
localStylingChanges,
|
||||
setLocalStylingChanges,
|
||||
isUnsplashConfigured,
|
||||
isCxMode,
|
||||
}: StylingViewProps) => {
|
||||
const stylingDefaults: TBaseStyling = useMemo(() => {
|
||||
let stylingDefaults: TBaseStyling;
|
||||
@@ -197,28 +199,30 @@ export const StylingView = ({
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<div className="mt-12 space-y-3 p-5">
|
||||
<div className="flex items-center gap-4 py-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="overwriteThemeStyling"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch checked={!!field.value} onCheckedChange={handleOverwriteToggle} />
|
||||
</FormControl>
|
||||
{!isCxMode && (
|
||||
<div className="flex items-center gap-4 py-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="overwriteThemeStyling"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch checked={!!field.value} onCheckedChange={handleOverwriteToggle} />
|
||||
</FormControl>
|
||||
|
||||
<div>
|
||||
<FormLabel className="text-base font-semibold text-slate-900">
|
||||
Add custom styles
|
||||
</FormLabel>
|
||||
<FormDescription className="text-sm text-slate-800">
|
||||
Override the theme with individual styles for this survey.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel className="text-base font-semibold text-slate-900">
|
||||
Add custom styles
|
||||
</FormLabel>
|
||||
<FormDescription className="text-sm text-slate-800">
|
||||
Override the theme with individual styles for this survey.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormStylingSettings
|
||||
open={formStylingOpen}
|
||||
@@ -248,31 +252,32 @@ export const StylingView = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex h-8 items-center justify-between">
|
||||
<div>
|
||||
{overwriteThemeStyling && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setConfirmResetStylingModalOpen(true)}>
|
||||
Reset to theme styles
|
||||
<RotateCcwIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{!isCxMode && (
|
||||
<div className="mt-4 flex h-8 items-center justify-between">
|
||||
<div>
|
||||
{overwriteThemeStyling && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setConfirmResetStylingModalOpen(true)}>
|
||||
Reset to theme styles
|
||||
<RotateCcwIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">
|
||||
Adjust the theme in the{" "}
|
||||
<Link
|
||||
href={`/environments/${environment.id}/product/look`}
|
||||
target="_blank"
|
||||
className="font-semibold underline">
|
||||
Look & Feel
|
||||
</Link>{" "}
|
||||
settings
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">
|
||||
Adjust the theme in the{" "}
|
||||
<Link
|
||||
href={`/environments/${environment.id}/product/look`}
|
||||
target="_blank"
|
||||
className="font-semibold underline">
|
||||
Look & Feel
|
||||
</Link>{" "}
|
||||
settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
)}
|
||||
<AlertDialog
|
||||
open={confirmResetStylingModalOpen}
|
||||
setOpen={setConfirmResetStylingModalOpen}
|
||||
|
||||
@@ -37,6 +37,7 @@ interface SurveyEditorProps {
|
||||
isFormbricksCloud: boolean;
|
||||
isUnsplashConfigured: boolean;
|
||||
plan: TOrganizationBillingPlan;
|
||||
isCxMode: boolean;
|
||||
}
|
||||
|
||||
export const SurveyEditor = ({
|
||||
@@ -55,6 +56,7 @@ export const SurveyEditor = ({
|
||||
isFormbricksCloud,
|
||||
isUnsplashConfigured,
|
||||
plan,
|
||||
isCxMode = false,
|
||||
}: SurveyEditorProps) => {
|
||||
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("questions");
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
|
||||
@@ -144,6 +146,7 @@ export const SurveyEditor = ({
|
||||
responseCount={responseCount}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
<div className="relative z-0 flex flex-1 overflow-hidden">
|
||||
<main
|
||||
@@ -152,6 +155,7 @@ export const SurveyEditor = ({
|
||||
<QuestionsAudienceTabs
|
||||
activeId={activeView}
|
||||
setActiveId={setActiveView}
|
||||
isCxMode={isCxMode}
|
||||
isStylingTabVisible={!!product.styling.allowStyleOverwrite}
|
||||
/>
|
||||
|
||||
@@ -170,6 +174,7 @@ export const SurveyEditor = ({
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
attributeClasses={attributeClasses}
|
||||
plan={plan}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -185,6 +190,7 @@ export const SurveyEditor = ({
|
||||
localStylingChanges={localStylingChanges}
|
||||
setLocalStylingChanges={setLocalStylingChanges}
|
||||
isUnsplashConfigured={isUnsplashConfigured}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ interface SurveyMenuBarProps {
|
||||
responseCount: number;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (selectedLanguage: string) => void;
|
||||
isCxMode: boolean;
|
||||
}
|
||||
|
||||
export const SurveyMenuBar = ({
|
||||
@@ -52,6 +53,7 @@ export const SurveyMenuBar = ({
|
||||
product,
|
||||
responseCount,
|
||||
selectedLanguageCode,
|
||||
isCxMode,
|
||||
}: SurveyMenuBarProps) => {
|
||||
const router = useRouter();
|
||||
const [audiencePrompt, setAudiencePrompt] = useState(true);
|
||||
@@ -86,6 +88,13 @@ export const SurveyMenuBar = ({
|
||||
};
|
||||
}, [localSurvey, survey]);
|
||||
|
||||
const clearSurveyLocalStorage = () => {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.removeItem(`${localSurvey.id}-columnOrder`);
|
||||
localStorage.removeItem(`${localSurvey.id}-columnVisibility`);
|
||||
}
|
||||
};
|
||||
|
||||
const containsEmptyTriggers = useMemo(() => {
|
||||
if (localSurvey.type === "link") return false;
|
||||
|
||||
@@ -233,6 +242,7 @@ export const SurveyMenuBar = ({
|
||||
}
|
||||
|
||||
const segment = await handleSegmentUpdate();
|
||||
clearSurveyLocalStorage();
|
||||
const updatedSurveyResponse = await updateSurveyAction({ ...localSurvey, segment });
|
||||
|
||||
setIsSurveySaving(false);
|
||||
@@ -278,6 +288,7 @@ export const SurveyMenuBar = ({
|
||||
}
|
||||
const status = localSurvey.runOnDate ? "scheduled" : "inProgress";
|
||||
const segment = await handleSegmentUpdate();
|
||||
clearSurveyLocalStorage();
|
||||
|
||||
await updateSurveyAction({
|
||||
...localSurvey,
|
||||
@@ -296,16 +307,18 @@ export const SurveyMenuBar = ({
|
||||
<>
|
||||
<div className="border-b border-slate-200 bg-white px-5 py-2.5 sm:flex sm:items-center sm:justify-between">
|
||||
<div className="flex h-full items-center space-x-2 whitespace-nowrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-full"
|
||||
StartIcon={ArrowLeftIcon}
|
||||
onClick={() => {
|
||||
handleBack();
|
||||
}}>
|
||||
Back
|
||||
</Button>
|
||||
{!isCxMode && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-full"
|
||||
StartIcon={ArrowLeftIcon}
|
||||
onClick={() => {
|
||||
handleBack();
|
||||
}}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<p className="hidden pl-4 font-semibold md:block">{product.name} / </p>
|
||||
<Input
|
||||
defaultValue={localSurvey.name}
|
||||
@@ -341,16 +354,19 @@ export const SurveyMenuBar = ({
|
||||
updateLocalSurveyStatus={updateLocalSurveyStatus}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={disableSave}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mr-3"
|
||||
loading={isSurveySaving}
|
||||
onClick={() => handleSurveySave()}
|
||||
type="submit">
|
||||
Save
|
||||
</Button>
|
||||
{!isCxMode && (
|
||||
<Button
|
||||
disabled={disableSave}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mr-3"
|
||||
loading={isSurveySaving}
|
||||
onClick={() => handleSurveySave()}
|
||||
type="submit">
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{localSurvey.status !== "draft" && (
|
||||
<Button
|
||||
disabled={disableSave}
|
||||
@@ -379,7 +395,7 @@ export const SurveyMenuBar = ({
|
||||
disabled={isSurveySaving || containsEmptyTriggers}
|
||||
loading={isSurveyPublishing}
|
||||
onClick={handleSurveyPublish}>
|
||||
Publish
|
||||
{isCxMode ? "Save & Close" : "Publish"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ export const generateMetadata = async ({ params }) => {
|
||||
};
|
||||
};
|
||||
|
||||
const Page = async ({ params }) => {
|
||||
const Page = async ({ params, searchParams }) => {
|
||||
const [
|
||||
survey,
|
||||
product,
|
||||
@@ -70,6 +70,8 @@ const Page = async ({ params }) => {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
|
||||
const isCxMode = searchParams.mode === "cx";
|
||||
|
||||
return (
|
||||
<SurveyEditor
|
||||
survey={survey}
|
||||
@@ -87,6 +89,7 @@ const Page = async ({ params }) => {
|
||||
plan={organization.billing.plan}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isUnsplashConfigured={UNSPLASH_ACCESS_KEY ? true : false}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
||||
import { TagIcon } from "lucide-react";
|
||||
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
||||
import { PageHeader } from "@formbricks/ui/PageHeader";
|
||||
@@ -8,7 +8,7 @@ const Loading = () => {
|
||||
<>
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="People">
|
||||
<PeopleSecondaryNavigation activeId="attributes" loading />
|
||||
<PersonSecondaryNavigation activeId="attributes" loading />
|
||||
</PageHeader>
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-5 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
||||
import { CircleHelpIcon } from "lucide-react";
|
||||
import { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
@@ -42,7 +42,7 @@ const Page = async ({ params }) => {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="People" cta={HowToAddAttributesButton}>
|
||||
<PeopleSecondaryNavigation activeId="attributes" environmentId={params.environmentId} />
|
||||
<PersonSecondaryNavigation activeId="attributes" environmentId={params.environmentId} />
|
||||
</PageHeader>
|
||||
<AttributeClassesTable attributeClasses={attributeClasses} />
|
||||
</PageContentWrapper>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { deletePersonAction } from "@/app/(app)/environments/[environmentId]/(people)/people/[personId]/actions";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { deletePersonAction } from "@formbricks/ui/DataTable/actions";
|
||||
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
|
||||
|
||||
interface DeletePersonButtonProps {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { getAttributes } from "@formbricks/lib/attribute/service";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils";
|
||||
import { getPeople } from "@formbricks/lib/person/service";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
const ZGetPersonsAction = z.object({
|
||||
environmentId: ZId,
|
||||
page: z.number(),
|
||||
});
|
||||
|
||||
export const getPersonsAction = authenticatedActionClient
|
||||
.schema(ZGetPersonsAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
rules: ["environment", "read"],
|
||||
});
|
||||
|
||||
return getPeople(parsedInput.environmentId, parsedInput.page);
|
||||
});
|
||||
|
||||
const ZGetPersonAttributesAction = z.object({
|
||||
environmentId: ZId,
|
||||
personId: ZId,
|
||||
});
|
||||
|
||||
export const getPersonAttributesAction = authenticatedActionClient
|
||||
.schema(ZGetPersonAttributesAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
rules: ["environment", "read"],
|
||||
});
|
||||
|
||||
return getAttributes(parsedInput.personId);
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import { getAttributes } from "@formbricks/lib/attribute/service";
|
||||
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
|
||||
import { TPerson } from "@formbricks/types/people";
|
||||
import { PersonAvatar } from "@formbricks/ui/Avatars";
|
||||
|
||||
export const PersonCard = async ({ person }: { person: TPerson }) => {
|
||||
const attributes = await getAttributes(person.id);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/environments/${person.environmentId}/people/${person.id}`}
|
||||
key={person.id}
|
||||
className="w-full">
|
||||
<div className="m-2 grid h-16 grid-cols-7 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
|
||||
<div className="col-span-3 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="ph-no-capture h-10 w-10 flex-shrink-0">
|
||||
<PersonAvatar personId={person.id} />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="ph-no-capture font-medium text-slate-900">
|
||||
<span>{getPersonIdentifier({ id: person.id, userId: person.userId }, attributes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{person.userId}</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{attributes.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { getPersonsAction } from "@/app/(app)/environments/[environmentId]/(people)/people/actions";
|
||||
import { PersonTable } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonTable";
|
||||
import { useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TPersonWithAttributes } from "@formbricks/types/people";
|
||||
|
||||
interface PersonDataViewProps {
|
||||
environment: TEnvironment;
|
||||
personCount: number;
|
||||
itemsPerPage: number;
|
||||
}
|
||||
|
||||
export const PersonDataView = ({ environment, personCount, itemsPerPage }: PersonDataViewProps) => {
|
||||
const [persons, setPersons] = useState<TPersonWithAttributes[]>([]);
|
||||
const [pageNumber, setPageNumber] = useState<number>(1);
|
||||
const [totalPersons, setTotalPersons] = useState<number>(0);
|
||||
const [isDataLoaded, setIsDataLoaded] = useState<boolean>(false);
|
||||
const [hasMore, setHasMore] = useState<boolean>(false);
|
||||
const [loadingNextPage, setLoadingNextPage] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTotalPersons(personCount);
|
||||
setHasMore(pageNumber < Math.ceil(personCount / itemsPerPage));
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const getPersonActionData = await getPersonsAction({
|
||||
environmentId: environment.id,
|
||||
page: pageNumber,
|
||||
});
|
||||
if (getPersonActionData?.data) {
|
||||
setPersons(getPersonActionData.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching people data:", error);
|
||||
} finally {
|
||||
setIsDataLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [pageNumber, personCount, itemsPerPage, environment.id]);
|
||||
|
||||
const fetchNextPage = async () => {
|
||||
if (hasMore && !loadingNextPage) {
|
||||
setLoadingNextPage(true);
|
||||
const getPersonsActionData = await getPersonsAction({
|
||||
environmentId: environment.id,
|
||||
page: pageNumber,
|
||||
});
|
||||
if (getPersonsActionData?.data) {
|
||||
const newData = getPersonsActionData.data;
|
||||
setPersons((prevPersonsData) => [...prevPersonsData, ...newData]);
|
||||
}
|
||||
setPageNumber((prevPage) => prevPage + 1);
|
||||
setHasMore(pageNumber + 1 < Math.ceil(totalPersons / itemsPerPage));
|
||||
setLoadingNextPage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deletePersons = (personIds: string[]) => {
|
||||
setPersons((prevPersons) => prevPersons.filter((p) => !personIds.includes(p.id)));
|
||||
};
|
||||
|
||||
const personTableData = persons.map((person) => ({
|
||||
id: person.id,
|
||||
userId: person.userId,
|
||||
email: person.attributes.email,
|
||||
createdAt: person.createdAt,
|
||||
attributes: person.attributes,
|
||||
personId: person.id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<PersonTable
|
||||
data={personTableData}
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasMore={hasMore}
|
||||
isDataLoaded={isDataLoaded}
|
||||
deletePersons={deletePersons}
|
||||
environmentId={environment.id}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -2,17 +2,17 @@ import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { TProductConfigChannel } from "@formbricks/types/product";
|
||||
import { SecondaryNavigation } from "@formbricks/ui/SecondaryNavigation";
|
||||
|
||||
interface PeopleSegmentsTabsProps {
|
||||
interface PersonSecondaryNavigationProps {
|
||||
activeId: string;
|
||||
environmentId?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const PeopleSecondaryNavigation = async ({
|
||||
export const PersonSecondaryNavigation = async ({
|
||||
activeId,
|
||||
environmentId,
|
||||
loading,
|
||||
}: PeopleSegmentsTabsProps) => {
|
||||
}: PersonSecondaryNavigationProps) => {
|
||||
let currentProductChannel: TProductConfigChannel = null;
|
||||
|
||||
if (!loading && environmentId) {
|
||||
@@ -0,0 +1,240 @@
|
||||
import { generatePersonTableColumns } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonTableColumn";
|
||||
import {
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
|
||||
import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { VisibilityState, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TPersonTableData } from "@formbricks/types/people";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { DataTableHeader, DataTableSettingsModal, DataTableToolbar } from "@formbricks/ui/DataTable";
|
||||
import { getCommonPinningStyles } from "@formbricks/ui/DataTable/lib/utils";
|
||||
import { Skeleton } from "@formbricks/ui/Skeleton";
|
||||
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@formbricks/ui/Table";
|
||||
|
||||
interface PersonTableProps {
|
||||
data: TPersonTableData[];
|
||||
fetchNextPage: () => void;
|
||||
hasMore: boolean;
|
||||
deletePersons: (personIds: string[]) => void;
|
||||
isDataLoaded: boolean;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const PersonTable = ({
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasMore,
|
||||
deletePersons,
|
||||
isDataLoaded,
|
||||
environmentId,
|
||||
}: PersonTableProps) => {
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||
const [isTableSettingsModalOpen, setIsTableSettingsModalOpen] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const router = useRouter();
|
||||
// Generate columns
|
||||
const columns = useMemo(() => generatePersonTableColumns(isExpanded ?? false), [isExpanded]);
|
||||
|
||||
// Load saved settings from localStorage
|
||||
useEffect(() => {
|
||||
const savedColumnOrder = localStorage.getItem(`${environmentId}-columnOrder`);
|
||||
const savedColumnVisibility = localStorage.getItem(`${environmentId}-columnVisibility`);
|
||||
const savedExpandedSettings = localStorage.getItem(`${environmentId}-rowExpand`);
|
||||
if (savedColumnOrder && JSON.parse(savedColumnOrder).length > 0) {
|
||||
setColumnOrder(JSON.parse(savedColumnOrder));
|
||||
} else {
|
||||
setColumnOrder(table.getAllLeafColumns().map((d) => d.id));
|
||||
}
|
||||
|
||||
if (savedColumnVisibility) {
|
||||
setColumnVisibility(JSON.parse(savedColumnVisibility));
|
||||
}
|
||||
if (savedExpandedSettings !== null) {
|
||||
setIsExpanded(JSON.parse(savedExpandedSettings));
|
||||
}
|
||||
}, [environmentId]);
|
||||
|
||||
// Save settings to localStorage when they change
|
||||
useEffect(() => {
|
||||
if (columnOrder.length > 0) {
|
||||
localStorage.setItem(`${environmentId}-columnOrder`, JSON.stringify(columnOrder));
|
||||
}
|
||||
if (Object.keys(columnVisibility).length > 0) {
|
||||
localStorage.setItem(`${environmentId}-columnVisibility`, JSON.stringify(columnVisibility));
|
||||
}
|
||||
|
||||
if (isExpanded !== null) {
|
||||
localStorage.setItem(`${environmentId}-rowExpand`, JSON.stringify(isExpanded));
|
||||
}
|
||||
}, [columnOrder, columnVisibility, isExpanded, environmentId]);
|
||||
|
||||
// Initialize DnD sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
);
|
||||
|
||||
// Memoize table data and columns
|
||||
const tableData: TPersonTableData[] = useMemo(
|
||||
() => (!isDataLoaded ? Array(10).fill({}) : data),
|
||||
[data, isDataLoaded]
|
||||
);
|
||||
const tableColumns = useMemo(
|
||||
() =>
|
||||
!isDataLoaded
|
||||
? columns.map((column) => ({
|
||||
...column,
|
||||
cell: () => (
|
||||
<Skeleton className="w-full">
|
||||
<div className="h-6"></div>
|
||||
</Skeleton>
|
||||
),
|
||||
}))
|
||||
: columns,
|
||||
[columns, data]
|
||||
);
|
||||
|
||||
// React Table instance
|
||||
const table = useReactTable({
|
||||
data: tableData,
|
||||
columns: tableColumns,
|
||||
getRowId: (originalRow) => originalRow.personId,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onColumnOrderChange: setColumnOrder,
|
||||
columnResizeMode: "onChange",
|
||||
columnResizeDirection: "ltr",
|
||||
manualPagination: true,
|
||||
defaultColumn: { size: 300 },
|
||||
state: {
|
||||
columnOrder,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnPinning: {
|
||||
left: ["select", "createdAt"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Handle column drag end
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (active && over && active.id !== over.id) {
|
||||
setColumnOrder((prevOrder) => {
|
||||
const oldIndex = prevOrder.indexOf(active.id as string);
|
||||
const newIndex = prevOrder.indexOf(over.id as string);
|
||||
return arrayMove(prevOrder, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToHorizontalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
sensors={sensors}>
|
||||
<DataTableToolbar
|
||||
setIsExpanded={setIsExpanded}
|
||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||
isExpanded={isExpanded ?? false}
|
||||
table={table}
|
||||
deleteRows={deletePersons}
|
||||
type="person"
|
||||
/>
|
||||
<div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-200">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<Table style={{ width: table.getCenterTotalSize(), tableLayout: "fixed" }}>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
<SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<DataTableHeader
|
||||
key={header.id}
|
||||
header={header}
|
||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={"group cursor-pointer"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
onClick={() => {
|
||||
if (cell.column.id === "select") return;
|
||||
router.push(`/environments/${environmentId}/people/${row.id}`);
|
||||
}}
|
||||
style={cell.column.id === "select" ? getCommonPinningStyles(cell.column) : {}}
|
||||
className={cn(
|
||||
"border-slate-200 bg-white shadow-none group-hover:bg-slate-100",
|
||||
row.getIsSelected() && "bg-slate-100",
|
||||
{
|
||||
"border-r": !cell.column.getIsLastColumn(),
|
||||
"border-l": !cell.column.getIsFirstColumn(),
|
||||
}
|
||||
)}>
|
||||
<div
|
||||
className={cn("flex flex-1 items-center truncate", isExpanded ? "h-full" : "h-10")}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
{table.getRowModel().rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data && hasMore && data.length > 0 && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button onClick={fetchNextPage} className="bg-blue-500 text-white">
|
||||
Load More
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTableSettingsModal
|
||||
open={isTableSettingsModalOpen}
|
||||
setOpen={setIsTableSettingsModalOpen}
|
||||
table={table}
|
||||
columnOrder={columnOrder}
|
||||
handleDragEnd={handleDragEnd}
|
||||
/>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TPersonTableData } from "@formbricks/types/people";
|
||||
import { getSelectionColumn } from "@formbricks/ui/DataTable";
|
||||
|
||||
export const generatePersonTableColumns = (isExpanded: boolean): ColumnDef<TPersonTableData>[] => {
|
||||
const dateColumn: ColumnDef<TPersonTableData> = {
|
||||
accessorKey: "createdAt",
|
||||
header: () => "Date",
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const isoDateString = row.original.createdAt;
|
||||
const date = new Date(isoDateString);
|
||||
|
||||
const formattedDate = date.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const formattedTime = date.toLocaleString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="truncate text-slate-900">{formattedDate}</p>
|
||||
<p className="truncate text-slate-900">{formattedTime}</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const userColumn: ColumnDef<TPersonTableData> = {
|
||||
accessorKey: "user",
|
||||
header: "User",
|
||||
cell: ({ row }) => {
|
||||
const personId = row.original.personId;
|
||||
return <p className="truncate text-slate-900">{personId}</p>;
|
||||
},
|
||||
};
|
||||
|
||||
const userIdColumn: ColumnDef<TPersonTableData> = {
|
||||
accessorKey: "userId",
|
||||
header: "User ID",
|
||||
cell: ({ row }) => {
|
||||
const userId = row.original.userId;
|
||||
return <p className="truncate text-slate-900">{userId}</p>;
|
||||
},
|
||||
};
|
||||
|
||||
const emailColumn: ColumnDef<TPersonTableData> = {
|
||||
accessorKey: "email",
|
||||
header: "Email",
|
||||
};
|
||||
|
||||
const attributesColumn: ColumnDef<TPersonTableData> = {
|
||||
accessorKey: "attributes",
|
||||
header: "Attributes",
|
||||
cell: ({ row }) => {
|
||||
const attributes = row.original.attributes;
|
||||
|
||||
// Handle cases where attributes are missing or empty
|
||||
if (!attributes || Object.keys(attributes).length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cn(!isExpanded && "flex space-x-2")}>
|
||||
{Object.entries(attributes).map(([key, value]) => (
|
||||
<div key={key} className="flex space-x-2">
|
||||
<div className="font-semibold">{key}</div> : <div>{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return [getSelectionColumn(), dateColumn, userColumn, userIdColumn, emailColumn, attributesColumn];
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
export const Pagination = ({ environmentId, currentPage, totalItems, itemsPerPage }) => {
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
|
||||
const previousPageLink =
|
||||
currentPage === 1 ? "#" : `/environments/${environmentId}/people?page=${currentPage - 1}`;
|
||||
const nextPageLink =
|
||||
currentPage === totalPages ? "#" : `/environments/${environmentId}/people?page=${currentPage + 1}`;
|
||||
|
||||
return (
|
||||
<nav aria-label="Page navigation" className="flex justify-center">
|
||||
<ul className="mt-4 inline-flex -space-x-px text-sm">
|
||||
<li>
|
||||
<a
|
||||
href={previousPageLink}
|
||||
className={`ml-0 flex h-8 items-center justify-center rounded-l-lg border border-slate-300 bg-white px-3 text-slate-500 ${
|
||||
currentPage === 1
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: "hover:bg-slate-100 hover:text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-white"
|
||||
}`}>
|
||||
Previous
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{Array.from({ length: totalPages }).map((_, idx) => {
|
||||
const pageNum = idx + 1;
|
||||
const pageLink = `/environments/${environmentId}/people?page=${pageNum}`;
|
||||
|
||||
return (
|
||||
<li key={pageNum} className="hidden sm:block">
|
||||
<a
|
||||
href={pageNum === currentPage ? "#" : pageLink}
|
||||
className={`flex h-8 items-center justify-center px-3 ${
|
||||
pageNum === currentPage ? "bg-blue-50 text-green-500" : "bg-white text-slate-500"
|
||||
} border border-slate-300 hover:bg-slate-100 hover:text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-white`}>
|
||||
{pageNum}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
<li>
|
||||
<a
|
||||
href={nextPageLink}
|
||||
className={`ml-0 flex h-8 items-center justify-center rounded-r-lg border border-slate-300 bg-white px-3 text-slate-500 ${
|
||||
currentPage === totalPages
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: "hover:bg-slate-100 hover:text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-white"
|
||||
}`}>
|
||||
Next
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
||||
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
||||
import { PageHeader } from "@formbricks/ui/PageHeader";
|
||||
|
||||
@@ -7,7 +7,7 @@ const Loading = () => {
|
||||
<>
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="People">
|
||||
<PeopleSecondaryNavigation activeId="people" loading />
|
||||
<PersonSecondaryNavigation activeId="people" loading />
|
||||
</PageHeader>
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
|
||||
@@ -1,42 +1,20 @@
|
||||
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||
import { PersonDataView } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonDataView";
|
||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
||||
import { CircleHelpIcon } from "lucide-react";
|
||||
import { ITEMS_PER_PAGE } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getPeople, getPeopleCount } from "@formbricks/lib/person/service";
|
||||
import { TPerson } from "@formbricks/types/people";
|
||||
import { getPersonCount } from "@formbricks/lib/person/service";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { EmptySpaceFiller } from "@formbricks/ui/EmptySpaceFiller";
|
||||
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
||||
import { PageHeader } from "@formbricks/ui/PageHeader";
|
||||
import { Pagination } from "@formbricks/ui/Pagination";
|
||||
import { PersonCard } from "./components/PersonCard";
|
||||
|
||||
const Page = async ({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { environmentId: string };
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
}) => {
|
||||
const pageNumber = searchParams.page ? parseInt(searchParams.page as string) : 1;
|
||||
const [environment, totalPeople] = await Promise.all([
|
||||
getEnvironment(params.environmentId),
|
||||
getPeopleCount(params.environmentId),
|
||||
]);
|
||||
const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
const personCount = await getPersonCount(params.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
const maxPageNumber = Math.ceil(totalPeople / ITEMS_PER_PAGE);
|
||||
let hidePagination = false;
|
||||
|
||||
let people: TPerson[] = [];
|
||||
|
||||
if (pageNumber < 1 || pageNumber > maxPageNumber) {
|
||||
people = [];
|
||||
hidePagination = true;
|
||||
} else {
|
||||
people = await getPeople(params.environmentId, pageNumber);
|
||||
}
|
||||
|
||||
const HowToAddPeopleButton = (
|
||||
<Button
|
||||
@@ -52,35 +30,9 @@ const Page = async ({
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="People" cta={HowToAddPeopleButton}>
|
||||
<PeopleSecondaryNavigation activeId="people" environmentId={params.environmentId} />
|
||||
<PersonSecondaryNavigation activeId="people" environmentId={params.environmentId} />
|
||||
</PageHeader>
|
||||
{people.length === 0 ? (
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environment={environment}
|
||||
emptyMessage="Your users will appear here as soon as they use your app ⏲️"
|
||||
noWidgetRequired={true}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-7 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-3 pl-6">User</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">User ID</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">Email</div>
|
||||
</div>
|
||||
{people.map((person) => (
|
||||
<PersonCard person={person} key={person.id} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hidePagination ? null : (
|
||||
<Pagination
|
||||
baseUrl={`/environments/${params.environmentId}/people`}
|
||||
currentPage={pageNumber}
|
||||
totalItems={totalPeople}
|
||||
itemsPerPage={ITEMS_PER_PAGE}
|
||||
/>
|
||||
)}
|
||||
<PersonDataView environment={environment} personCount={personCount} itemsPerPage={ITEMS_PER_PAGE} />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
||||
import { UsersIcon } from "lucide-react";
|
||||
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
||||
import { PageHeader } from "@formbricks/ui/PageHeader";
|
||||
@@ -8,7 +8,7 @@ const Loading = () => {
|
||||
<>
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="People">
|
||||
<PeopleSecondaryNavigation activeId="segments" loading />
|
||||
<PersonSecondaryNavigation activeId="segments" loading />
|
||||
</PageHeader>
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
||||
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/advanced-targeting/components/create-segment-modal";
|
||||
@@ -56,7 +56,7 @@ const Page = async ({ params }) => {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="People" cta={renderCreateSegmentButton()}>
|
||||
<PeopleSecondaryNavigation activeId="segments" environmentId={params.environmentId} />
|
||||
<PersonSecondaryNavigation activeId="segments" environmentId={params.environmentId} />
|
||||
</PageHeader>
|
||||
<SegmentTable
|
||||
segments={filteredSegments}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/actionClass/service";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { actionClient, authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { getOrganizationIdFromActionClassId } from "@formbricks/lib/organization/utils";
|
||||
import { getSurveysByActionClassId } from "@formbricks/lib/survey/service";
|
||||
import { ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
@@ -72,3 +73,33 @@ export const getActiveInactiveSurveysAction = authenticatedActionClient
|
||||
};
|
||||
return response;
|
||||
});
|
||||
|
||||
const getLatestStableFbRelease = async (): Promise<string | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
try {
|
||||
const res = await fetch("https://api.github.com/repos/formbricks/formbricks/releases");
|
||||
const releases = await res.json();
|
||||
|
||||
if (Array.isArray(releases)) {
|
||||
const latestStableReleaseTag = releases.filter((release) => !release.prerelease)?.[0]
|
||||
?.tag_name as string;
|
||||
if (latestStableReleaseTag) {
|
||||
return latestStableReleaseTag;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
["latest-fb-release"],
|
||||
{
|
||||
revalidate: 60 * 60 * 24, // 24 hours
|
||||
}
|
||||
)();
|
||||
|
||||
export const getLatestStableFbReleaseAction = actionClient.action(async () => {
|
||||
return await getLatestStableFbRelease();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[environmentId]/actions/actions";
|
||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||
import { formbricksLogout } from "@/app/lib/formbricks";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PlusIcon,
|
||||
RocketIcon,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
@@ -54,6 +56,7 @@ import {
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
import { version } from "../../../../../package.json";
|
||||
|
||||
interface NavigationProps {
|
||||
environment: TEnvironment;
|
||||
@@ -61,9 +64,9 @@ interface NavigationProps {
|
||||
user: TUser;
|
||||
organization: TOrganization;
|
||||
products: TProduct[];
|
||||
isFormbricksCloud: boolean;
|
||||
membershipRole?: TMembershipRole;
|
||||
isMultiOrgEnabled: boolean;
|
||||
isFormbricksCloud?: boolean;
|
||||
membershipRole?: TMembershipRole;
|
||||
}
|
||||
|
||||
export const MainNavigation = ({
|
||||
@@ -72,9 +75,9 @@ export const MainNavigation = ({
|
||||
organization,
|
||||
user,
|
||||
products,
|
||||
isFormbricksCloud,
|
||||
membershipRole,
|
||||
isMultiOrgEnabled,
|
||||
isFormbricksCloud = true,
|
||||
membershipRole,
|
||||
}: NavigationProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -84,6 +87,7 @@ export const MainNavigation = ({
|
||||
const [showCreateOrganizationModal, setShowCreateOrganizationModal] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
const [isTextVisible, setIsTextVisible] = useState(true);
|
||||
const [latestVersion, setLatestVersion] = useState("");
|
||||
|
||||
const product = products.find((product) => product.id === environment.productId);
|
||||
const { isAdmin, isOwner, isViewer } = getAccessFlags(membershipRole);
|
||||
@@ -165,7 +169,7 @@ export const MainNavigation = ({
|
||||
};
|
||||
|
||||
const handleAddProduct = (organizationId: string) => {
|
||||
router.push(`/organizations/${organizationId}/products/new/channel`);
|
||||
router.push(`/organizations/${organizationId}/products/new/mode`);
|
||||
};
|
||||
|
||||
const mainNavigation = useMemo(
|
||||
@@ -248,6 +252,21 @@ export const MainNavigation = ({
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
async function loadReleases() {
|
||||
const res = await getLatestStableFbReleaseAction();
|
||||
if (res?.data) {
|
||||
const latestVersionTag = res.data;
|
||||
const currentVersionTag = `v${version}`;
|
||||
|
||||
if (currentVersionTag !== latestVersionTag) {
|
||||
setLatestVersion(latestVersionTag);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isOwnerOrAdmin) loadReleases();
|
||||
}, [isOwnerOrAdmin]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{product && (
|
||||
@@ -305,8 +324,22 @@ export const MainNavigation = ({
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Product Switch */}
|
||||
<div>
|
||||
{/* New Version Available */}
|
||||
{!isCollapsed && isOwnerOrAdmin && latestVersion && !isFormbricksCloud && (
|
||||
<Link
|
||||
href="https://github.com/formbricks/formbricks/releases"
|
||||
target="_blank"
|
||||
className="m-2 flex items-center space-x-4 rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-800 hover:border-slate-300 hover:bg-slate-200">
|
||||
<p className="flex items-center justify-center gap-x-2 text-xs">
|
||||
<RocketIcon strokeWidth={1.5} className="mx-1 h-6 w-6 text-slate-900" />
|
||||
Formbricks {latestVersion} is here. Upgrade now!
|
||||
</p>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
|
||||
@@ -15,7 +15,7 @@ export const WidgetStatusIndicator = ({ environment, size, type }: WidgetStatusI
|
||||
notImplemented: {
|
||||
icon: AlertTriangleIcon,
|
||||
title: `Your ${type} is not yet connected.`,
|
||||
subtitle: `Connect your ${type} with Formbricks to get started. To run ${type === "app" ? "in-app" : "website"} surveys follow the setup guide.`,
|
||||
subtitle: ``,
|
||||
shortText: `Connect your ${type} with Formbricks`,
|
||||
},
|
||||
running: {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
TYPE_MAPPING,
|
||||
UNSUPPORTED_TYPES_BY_NOTION,
|
||||
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
|
||||
import { questionTypes } from "@/app/lib/questions";
|
||||
import NotionLogo from "@/images/notion.png";
|
||||
import { PlusIcon, XIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
@@ -13,6 +12,7 @@ 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 { questionTypes } from "@formbricks/lib/utils/questions";
|
||||
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TIntegrationInput } from "@formbricks/types/integration";
|
||||
|
||||
@@ -23,7 +23,7 @@ interface SetupInstructionsProps {
|
||||
}
|
||||
|
||||
export const SetupInstructions = ({ environmentId, webAppUrl, type }: SetupInstructionsProps) => {
|
||||
const [activeTab, setActiveTab] = useState(tabs[0].id);
|
||||
const [activeTab, setActiveTab] = useState(type === "website" ? tabs[1].id : tabs[0].id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -52,17 +52,17 @@ const Page = async ({ params }) => {
|
||||
description="Check if your website is successfully connected with Formbricks. Reload page to recheck.">
|
||||
{environment && <WidgetStatusIndicator environment={environment} size="large" type="website" />}
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="Your EnvironmentId"
|
||||
description="This id uniquely identifies this Formbricks environment.">
|
||||
<EnvironmentIdField environmentId={params.environmentId} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="How to setup"
|
||||
description="Follow these steps to setup the Formbricks widget within your website"
|
||||
noPadding>
|
||||
<SetupInstructions environmentId={params.environmentId} webAppUrl={WEBAPP_URL} type="website" />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="Your EnvironmentId"
|
||||
description="This id uniquely identifies this Formbricks environment.">
|
||||
<EnvironmentIdField environmentId={params.environmentId} />
|
||||
</SettingsCard>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
|
||||
@@ -35,7 +35,7 @@ export const OrganizationSettingsNavbar = ({
|
||||
label: "Billing & Plan",
|
||||
href: `/environments/${environmentId}/settings/billing`,
|
||||
icon: <CreditCardIcon className="h-5 w-5" />,
|
||||
hidden: !isFormbricksCloud || !isOwner,
|
||||
hidden: !isFormbricksCloud || isPricingDisabled,
|
||||
current: pathname?.includes("/billing"),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,8 +11,8 @@ import { SingleResponseCard } from "@formbricks/ui/SingleResponseCard";
|
||||
|
||||
interface ResponseCardModalProps {
|
||||
responses: TResponse[];
|
||||
selectedResponse: TResponse | null;
|
||||
setSelectedResponse: (response: TResponse | null) => void;
|
||||
selectedResponseId: string | null;
|
||||
setSelectedResponseId: (id: string | null) => void;
|
||||
survey: TSurvey;
|
||||
environment: TEnvironment;
|
||||
user?: TUser;
|
||||
@@ -26,8 +26,8 @@ interface ResponseCardModalProps {
|
||||
|
||||
export const ResponseCardModal = ({
|
||||
responses,
|
||||
selectedResponse,
|
||||
setSelectedResponse,
|
||||
selectedResponseId,
|
||||
setSelectedResponseId,
|
||||
survey,
|
||||
environment,
|
||||
user,
|
||||
@@ -41,33 +41,33 @@ export const ResponseCardModal = ({
|
||||
const [currentIndex, setCurrentIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedResponse) {
|
||||
if (selectedResponseId) {
|
||||
setOpen(true);
|
||||
const index = responses.findIndex((response) => response.id === selectedResponse.id);
|
||||
const index = responses.findIndex((response) => response.id === selectedResponseId);
|
||||
setCurrentIndex(index);
|
||||
} else {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [selectedResponse, responses, setOpen]);
|
||||
}, [selectedResponseId, responses, setOpen]);
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentIndex !== null && currentIndex < responses.length - 1) {
|
||||
setSelectedResponse(responses[currentIndex + 1]);
|
||||
setSelectedResponseId(responses[currentIndex + 1].id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentIndex !== null && currentIndex > 0) {
|
||||
setSelectedResponse(responses[currentIndex - 1]);
|
||||
setSelectedResponseId(responses[currentIndex - 1].id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedResponse(null);
|
||||
setSelectedResponseId(null);
|
||||
};
|
||||
|
||||
// If no response is selected or currentIndex is null, do not render the modal
|
||||
if (selectedResponse === null || currentIndex === null) return null;
|
||||
if (selectedResponseId === null || currentIndex === null) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -100,7 +100,7 @@ export const ResponseCardModal = ({
|
||||
</div>
|
||||
<SingleResponseCard
|
||||
survey={survey}
|
||||
response={selectedResponse}
|
||||
response={responses[currentIndex]}
|
||||
user={user}
|
||||
pageType="response"
|
||||
environment={environment}
|
||||
@@ -108,7 +108,7 @@ export const ResponseCardModal = ({
|
||||
isViewer={isViewer}
|
||||
updateResponse={updateResponse}
|
||||
deleteResponses={deleteResponses}
|
||||
setSelectedResponse={setSelectedResponse}
|
||||
setSelectedResponseId={setSelectedResponseId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { ResponseCardModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal";
|
||||
import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell";
|
||||
import { generateColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
|
||||
import { ResponseTableHeader } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableHeader";
|
||||
import { ResponseTableToolbar } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableToolbar";
|
||||
import { TableSettingsModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/TableSettingsModal";
|
||||
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
|
||||
import {
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
@@ -24,6 +21,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { DataTableHeader, DataTableSettingsModal, DataTableToolbar } from "@formbricks/ui/DataTable";
|
||||
import { Skeleton } from "@formbricks/ui/Skeleton";
|
||||
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@formbricks/ui/Table";
|
||||
|
||||
@@ -59,12 +57,46 @@ export const ResponseTable = ({
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [isTableSettingsModalOpen, setIsTableSettingsModalOpen] = useState(false);
|
||||
const [selectedResponse, setSelectedResponse] = useState<TResponse | null>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [selectedResponseId, setSelectedResponseId] = useState<string | null>(null);
|
||||
const selectedResponse = responses?.find((response) => response.id === selectedResponseId) ?? null;
|
||||
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||
|
||||
// Generate columns
|
||||
const columns = generateColumns(survey, isExpanded, isViewer);
|
||||
const columns = generateResponseTableColumns(survey, isExpanded ?? false, isViewer);
|
||||
|
||||
// Load saved settings from localStorage
|
||||
useEffect(() => {
|
||||
const savedColumnOrder = localStorage.getItem(`${survey.id}-columnOrder`);
|
||||
const savedColumnVisibility = localStorage.getItem(`${survey.id}-columnVisibility`);
|
||||
const savedExpandedSettings = localStorage.getItem(`${survey.id}-rowExpand`);
|
||||
|
||||
if (savedColumnOrder && JSON.parse(savedColumnOrder).length > 0) {
|
||||
setColumnOrder(JSON.parse(savedColumnOrder));
|
||||
} else {
|
||||
setColumnOrder(table.getAllLeafColumns().map((d) => d.id));
|
||||
}
|
||||
|
||||
if (savedColumnVisibility) {
|
||||
setColumnVisibility(JSON.parse(savedColumnVisibility));
|
||||
}
|
||||
if (savedExpandedSettings !== null) {
|
||||
setIsExpanded(JSON.parse(savedExpandedSettings));
|
||||
}
|
||||
}, [survey.id]);
|
||||
|
||||
// Save settings to localStorage when they change
|
||||
useEffect(() => {
|
||||
if (columnOrder.length > 0) {
|
||||
localStorage.setItem(`${survey.id}-columnOrder`, JSON.stringify(columnOrder));
|
||||
}
|
||||
if (Object.keys(columnVisibility).length > 0) {
|
||||
localStorage.setItem(`${survey.id}-columnVisibility`, JSON.stringify(columnVisibility));
|
||||
}
|
||||
if (isExpanded !== null) {
|
||||
localStorage.setItem(`${survey.id}-rowExpand`, JSON.stringify(isExpanded));
|
||||
}
|
||||
}, [columnOrder, columnVisibility, isExpanded, survey.id]);
|
||||
|
||||
// Initialize DnD sensors
|
||||
const sensors = useSensors(
|
||||
@@ -116,14 +148,6 @@ export const ResponseTable = ({
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Set initial column order
|
||||
const setInitialColumnOrder = () => {
|
||||
table.setColumnOrder(table.getAllLeafColumns().map((d) => d.id));
|
||||
};
|
||||
setInitialColumnOrder();
|
||||
}, [table]);
|
||||
|
||||
// Handle column drag end
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
@@ -139,62 +163,70 @@ export const ResponseTable = ({
|
||||
return (
|
||||
<div>
|
||||
<DndContext
|
||||
id="response-table"
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToHorizontalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
sensors={sensors}>
|
||||
<ResponseTableToolbar
|
||||
<DataTableToolbar
|
||||
setIsExpanded={setIsExpanded}
|
||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||
isExpanded={isExpanded}
|
||||
isExpanded={isExpanded ?? false}
|
||||
table={table}
|
||||
deleteResponses={deleteResponses}
|
||||
deleteRows={deleteResponses}
|
||||
type="response"
|
||||
/>
|
||||
<div>
|
||||
<Table style={{ width: table.getCenterTotalSize(), tableLayout: "fixed" }}>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
<SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<ResponseTableHeader
|
||||
key={header.id}
|
||||
header={header}
|
||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||
<div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-200">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<Table
|
||||
style={{
|
||||
width: table.getCenterTotalSize(),
|
||||
tableLayout: "fixed",
|
||||
}}>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
<SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<DataTableHeader
|
||||
key={header.id}
|
||||
header={header}
|
||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={"group cursor-pointer"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<ResponseTableCell
|
||||
key={cell.id}
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={isExpanded ?? false}
|
||||
setSelectedResponseId={setSelectedResponseId}
|
||||
responses={responses}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={"group cursor-pointer"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<ResponseTableCell
|
||||
key={cell.id}
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={isExpanded}
|
||||
setSelectedResponseCard={setSelectedResponse}
|
||||
responses={responses}
|
||||
/>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
{table.getRowModel().rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableRow>
|
||||
))}
|
||||
{table.getRowModel().rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data && hasMore && data.length > 0 && (
|
||||
@@ -205,7 +237,7 @@ export const ResponseTable = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TableSettingsModal
|
||||
<DataTableSettingsModal
|
||||
open={isTableSettingsModalOpen}
|
||||
setOpen={setIsTableSettingsModalOpen}
|
||||
survey={survey}
|
||||
@@ -224,12 +256,12 @@ export const ResponseTable = ({
|
||||
isViewer={isViewer}
|
||||
updateResponse={updateResponse}
|
||||
deleteResponses={deleteResponses}
|
||||
setSelectedResponse={setSelectedResponse}
|
||||
selectedResponse={selectedResponse}
|
||||
setSelectedResponseId={setSelectedResponseId}
|
||||
selectedResponseId={selectedResponseId}
|
||||
open={selectedResponse !== null}
|
||||
setOpen={(open) => {
|
||||
if (!open) {
|
||||
setSelectedResponse(null);
|
||||
setSelectedResponseId(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { getCommonPinningStyles } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableHeader";
|
||||
import { Cell, Row, flexRender } from "@tanstack/react-table";
|
||||
import { Maximize2Icon } from "lucide-react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
|
||||
import { getCommonPinningStyles } from "@formbricks/ui/DataTable/lib/utils";
|
||||
import { TableCell } from "@formbricks/ui/Table";
|
||||
|
||||
interface ResponseTableCellProps {
|
||||
cell: Cell<TResponseTableData, unknown>;
|
||||
row: Row<TResponseTableData>;
|
||||
isExpanded: boolean;
|
||||
setSelectedResponseCard: (responseCard: TResponse) => void;
|
||||
setSelectedResponseId: (responseId: string | null) => void;
|
||||
responses: TResponse[] | null;
|
||||
}
|
||||
|
||||
@@ -17,14 +17,14 @@ export const ResponseTableCell = ({
|
||||
cell,
|
||||
row,
|
||||
isExpanded,
|
||||
setSelectedResponseCard,
|
||||
setSelectedResponseId,
|
||||
responses,
|
||||
}: ResponseTableCellProps) => {
|
||||
// Function to handle cell click
|
||||
const handleCellClick = () => {
|
||||
if (cell.column.id !== "select") {
|
||||
const response = responses?.find((response) => response.id === row.id);
|
||||
if (response) setSelectedResponseCard(response);
|
||||
if (response) setSelectedResponseId(response.id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -46,8 +46,12 @@ export const ResponseTableCell = ({
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
"border border-slate-300 bg-white shadow-none group-hover:bg-slate-100",
|
||||
row.getIsSelected() && "bg-slate-100"
|
||||
"border-slate-200 bg-white shadow-none group-hover:bg-slate-100",
|
||||
row.getIsSelected() && "bg-slate-100",
|
||||
{
|
||||
"border-r": !cell.column.getIsLastColumn(),
|
||||
"border-l": !cell.column.getIsFirstColumn(),
|
||||
}
|
||||
)}
|
||||
style={cellStyles}
|
||||
onClick={handleCellClick}>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { QUESTIONS_ICON_MAP, VARIABLES_ICON_MAP } from "@/app/lib/questions";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
|
||||
import { processResponseData } from "@formbricks/lib/responses";
|
||||
import { QUESTIONS_ICON_MAP, VARIABLES_ICON_MAP } from "@formbricks/lib/utils/questions";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { Checkbox } from "@formbricks/ui/Checkbox";
|
||||
import { getSelectionColumn } from "@formbricks/ui/DataTable";
|
||||
import { ResponseBadges } from "@formbricks/ui/ResponseBadges";
|
||||
import { RenderResponse } from "@formbricks/ui/SingleResponseCard/components/RenderResponse";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
|
||||
@@ -123,7 +123,7 @@ const getQuestionColumnsData = (
|
||||
}
|
||||
};
|
||||
|
||||
export const generateColumns = (
|
||||
export const generateResponseTableColumns = (
|
||||
survey: TSurvey,
|
||||
isExpanded: boolean,
|
||||
isViewer: boolean
|
||||
@@ -131,30 +131,6 @@ export const generateColumns = (
|
||||
const questionColumns = survey.questions.flatMap((question) =>
|
||||
getQuestionColumnsData(question, survey, isExpanded)
|
||||
);
|
||||
const selectionColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "select",
|
||||
size: 75,
|
||||
enableResizing: false,
|
||||
header: ({ table }) => (
|
||||
<div className="flex w-full items-center justify-center pr-4">
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex w-full items-center justify-center pr-4">
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
className="mx-1"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
const dateColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "createdAt",
|
||||
@@ -317,8 +293,8 @@ export const generateColumns = (
|
||||
};
|
||||
|
||||
// Combine the selection column with the dynamic question columns
|
||||
return [
|
||||
...(isViewer ? [] : [selectionColumn]),
|
||||
|
||||
const baseColumns = [
|
||||
personColumn,
|
||||
dateColumn,
|
||||
statusColumn,
|
||||
@@ -329,4 +305,6 @@ export const generateColumns = (
|
||||
tagsColumn,
|
||||
notesColumn,
|
||||
];
|
||||
|
||||
return isViewer ? baseColumns : [getSelectionColumn(), ...baseColumns];
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { questionTypes } from "@/app/lib/questions";
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import { questionTypes } from "@formbricks/lib/utils/questions";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyQuestionSummary } from "@formbricks/types/surveys/types";
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { BellRing, BlocksIcon, Code2Icon, LinkIcon, MailIcon, UsersRound } from "lucide-react";
|
||||
import {
|
||||
BellRing,
|
||||
BlocksIcon,
|
||||
Code2Icon,
|
||||
LinkIcon,
|
||||
MailIcon,
|
||||
SmartphoneIcon,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { Badge } from "@formbricks/ui/Badge";
|
||||
import { Dialog, DialogContent } from "@formbricks/ui/Dialog";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@formbricks/ui/Dialog";
|
||||
import { ShareSurveyLink } from "@formbricks/ui/ShareSurveyLink";
|
||||
import { EmbedView } from "./shareEmbedModal/EmbedView";
|
||||
import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
|
||||
@@ -27,9 +35,10 @@ export const ShareEmbedSurvey = ({ survey, open, setOpen, webAppUrl, user }: Sha
|
||||
const { email } = user;
|
||||
|
||||
const tabs = [
|
||||
{ id: "email", label: "Embed in an Email", icon: MailIcon },
|
||||
{ id: "webpage", label: "Embed in a Web Page", icon: Code2Icon },
|
||||
{ id: "link", label: `${isSingleUseLinkSurvey ? "Single Use Links" : "Share the Link"}`, icon: LinkIcon },
|
||||
{ id: "email", label: "Embed in an email", icon: MailIcon },
|
||||
{ id: "webpage", label: "Embed on website", icon: Code2Icon },
|
||||
{ id: "link", label: `${isSingleUseLinkSurvey ? "Single use links" : "Share the link"}`, icon: LinkIcon },
|
||||
{ id: "app", label: "Embed in app", icon: SmartphoneIcon },
|
||||
];
|
||||
|
||||
const [activeId, setActiveId] = useState(tabs[0].id);
|
||||
@@ -55,7 +64,10 @@ export const ShareEmbedSurvey = ({ survey, open, setOpen, webAppUrl, user }: Sha
|
||||
{showView === "start" ? (
|
||||
<div className="h-full max-w-full overflow-hidden">
|
||||
<div className="flex h-[200px] w-full flex-col items-center justify-center space-y-6 p-8 text-center lg:h-2/5">
|
||||
<p className="pt-2 text-xl font-semibold text-slate-800">Your survey is public 🎉</p>
|
||||
<DialogTitle>
|
||||
<p className="pt-2 text-xl font-semibold text-slate-800">Your survey is public 🎉</p>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="hidden" />
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
webAppUrl={webAppUrl}
|
||||
@@ -74,7 +86,7 @@ export const ShareEmbedSurvey = ({ survey, open, setOpen, webAppUrl, user }: Sha
|
||||
Embed survey
|
||||
</button>
|
||||
<Link
|
||||
href={`/environments/${environmentId}//settings/notifications`}
|
||||
href={`/environments/${environmentId}/settings/notifications`}
|
||||
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-sm text-slate-500 hover:border-slate-200 md:p-8">
|
||||
<BellRing className="h-6 w-6 text-slate-700" />
|
||||
Configure alerts
|
||||
@@ -101,6 +113,7 @@ export const ShareEmbedSurvey = ({ survey, open, setOpen, webAppUrl, user }: Sha
|
||||
handleInitialPageButton={handleInitialPageButton}
|
||||
tabs={tabs}
|
||||
activeId={activeId}
|
||||
environmentId={environmentId}
|
||||
setActiveId={setActiveId}
|
||||
survey={survey}
|
||||
email={email}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AlertCircleIcon, CheckCircle2Icon } from "lucide-react";
|
||||
import { Clipboard } from "lucide-react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Dialog, DialogContent } from "@formbricks/ui/Dialog";
|
||||
import { Modal } from "@formbricks/ui/Modal";
|
||||
|
||||
interface ShareEmbedSurveyProps {
|
||||
open: boolean;
|
||||
@@ -21,13 +21,9 @@ export const ShareSurveyResults = ({
|
||||
surveyUrl,
|
||||
}: ShareEmbedSurveyProps) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
setOpen(open);
|
||||
}}>
|
||||
<Modal open={open} setOpen={setOpen} size="lg">
|
||||
{showPublishModal && surveyUrl ? (
|
||||
<DialogContent className="flex flex-col rounded-2xl bg-white px-12 py-6">
|
||||
<div className="flex flex-col rounded-2xl bg-white px-12 py-6">
|
||||
<div className="flex flex-col items-center gap-y-6 text-center">
|
||||
<CheckCircle2Icon className="h-20 w-20 text-slate-300" />
|
||||
<div>
|
||||
@@ -37,7 +33,6 @@ export const ShareSurveyResults = ({
|
||||
by search engines.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800">
|
||||
<span>{surveyUrl}</span>
|
||||
@@ -55,7 +50,6 @@ export const ShareSurveyResults = ({
|
||||
<Clipboard />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -64,15 +58,14 @@ export const ShareSurveyResults = ({
|
||||
onClick={() => handleUnpublish()}>
|
||||
Unpublish
|
||||
</Button>
|
||||
|
||||
<Button className="text-center" href={surveyUrl} target="_blank">
|
||||
View site
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</div>
|
||||
) : (
|
||||
<DialogContent className="flex flex-col rounded-2xl bg-white p-8">
|
||||
<div className="flex flex-col rounded-2xl bg-white p-8">
|
||||
<div className="flex flex-col items-center gap-y-6 text-center">
|
||||
<AlertCircleIcon className="h-20 w-20 text-slate-300" />
|
||||
<div>
|
||||
@@ -88,8 +81,8 @@ export const ShareSurveyResults = ({
|
||||
Publish to public web
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import ChangeSurveyTypeTip from "@/images/tooltips/change-survey-type-app.mp4";
|
||||
import { CogIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Alert, AlertDescription } from "@formbricks/ui/Alert";
|
||||
import { OptionsSwitch } from "@formbricks/ui/OptionsSwitch";
|
||||
|
||||
export const AppTab = ({ environmentId }) => {
|
||||
const [selectedTab, setSelectedTab] = useState("webapp");
|
||||
|
||||
return (
|
||||
<div className="flex h-full grow flex-col">
|
||||
<OptionsSwitch
|
||||
options={[
|
||||
{ value: "webapp", label: "Web app" },
|
||||
{ value: "mobile", label: "Mobile app" },
|
||||
]}
|
||||
currentOption={selectedTab}
|
||||
handleOptionChange={(value) => setSelectedTab(value)}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
{selectedTab === "webapp" ? <WebAppTab environmentId={environmentId} /> : <MobileAppTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileAppTab = () => {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-slate-800">How to embed a survey on your React Native app</p>
|
||||
<ol className="mt-4 list-decimal space-y-2 pl-5 text-sm text-slate-700">
|
||||
<li>
|
||||
Follow the{" "}
|
||||
<Link
|
||||
href="https://formbricks.com/docs/developer-docs/react-native-in-app-surveys"
|
||||
target="_blank"
|
||||
className="decoration-brand-dark font-medium underline underline-offset-2">
|
||||
setup instructions for React Native apps
|
||||
</Link>{" "}
|
||||
to connect your app with Formbricks
|
||||
</li>
|
||||
</ol>
|
||||
<Alert variant="default" className="mt-4">
|
||||
<AlertDescription className="flex gap-x-2">
|
||||
<CogIcon className="h-5 w-5 animate-spin" />
|
||||
<div>We're working on SDKs for Flutter, Swift and Kotlin.</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const WebAppTab = ({ environmentId }) => {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-slate-800">How to embed a survey on your web app</p>
|
||||
<ol className="mt-4 list-decimal space-y-2 pl-5 text-sm text-slate-700">
|
||||
<li>
|
||||
Follow these{" "}
|
||||
<Link
|
||||
href={`/environments/${environmentId}/product/app-connection`}
|
||||
target="_blank"
|
||||
className="decoration-brand-dark font-medium underline underline-offset-2">
|
||||
setup instructions
|
||||
</Link>{" "}
|
||||
to connect your web app with Formbricks
|
||||
</li>
|
||||
<li>
|
||||
Learn how to{" "}
|
||||
<Link
|
||||
href="https://formbricks.com/docs/app-surveys/user-identification"
|
||||
target="_blank"
|
||||
className="decoration-brand-dark font-medium underline underline-offset-2">
|
||||
identify users and set attrubutes
|
||||
</Link>{" "}
|
||||
to run highly targeted surveys.
|
||||
</li>
|
||||
<li>
|
||||
Make sure your survey type is set to <b>App survey</b>
|
||||
pop up
|
||||
</li>
|
||||
<li>Dfine when and where the survey should pop up</li>
|
||||
</ol>
|
||||
<div className="mt-4">
|
||||
<video autoPlay loop muted className="w-full rounded-xl border border-slate-200">
|
||||
<source src={ChangeSurveyTypeTip} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,15 +3,17 @@
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { AppTab } from "./AppTab";
|
||||
import { EmailTab } from "./EmailTab";
|
||||
import { LinkTab } from "./LinkTab";
|
||||
import { WebpageTab } from "./WebpageTab";
|
||||
import { WebsiteTab } from "./WebsiteTab";
|
||||
|
||||
interface EmbedViewProps {
|
||||
handleInitialPageButton: () => void;
|
||||
tabs: Array<{ id: string; label: string; icon: any }>;
|
||||
activeId: string;
|
||||
setActiveId: React.Dispatch<React.SetStateAction<string>>;
|
||||
environmentId: string;
|
||||
survey: any;
|
||||
email: string;
|
||||
surveyUrl: string;
|
||||
@@ -24,6 +26,7 @@ export const EmbedView = ({
|
||||
tabs,
|
||||
activeId,
|
||||
setActiveId,
|
||||
environmentId,
|
||||
survey,
|
||||
email,
|
||||
surveyUrl,
|
||||
@@ -67,7 +70,7 @@ export const EmbedView = ({
|
||||
{activeId === "email" ? (
|
||||
<EmailTab surveyId={survey.id} email={email} />
|
||||
) : activeId === "webpage" ? (
|
||||
<WebpageTab surveyUrl={surveyUrl} />
|
||||
<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />
|
||||
) : activeId === "link" ? (
|
||||
<LinkTab
|
||||
survey={survey}
|
||||
@@ -75,6 +78,8 @@ export const EmbedView = ({
|
||||
surveyUrl={surveyUrl}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
/>
|
||||
) : activeId === "app" ? (
|
||||
<AppTab environmentId={environmentId} />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 rounded-md p-3 text-center lg:hidden">
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CopyIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { CodeBlock } from "@formbricks/ui/CodeBlock";
|
||||
|
||||
export const WebpageTab = ({ surveyUrl }) => {
|
||||
const [embedModeEnabled, setEmbedModeEnabled] = useState(false);
|
||||
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
|
||||
<iframe
|
||||
src="${surveyUrl}${embedModeEnabled ? "?embed=true" : ""}"
|
||||
frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">
|
||||
</iframe>
|
||||
</div>`;
|
||||
|
||||
return (
|
||||
<div className="flex h-full grow flex-col">
|
||||
<div className="flex justify-between">
|
||||
<div></div>
|
||||
<Button
|
||||
title="Embed survey in your website"
|
||||
aria-label="Embed survey in your website"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(iframeCode);
|
||||
toast.success("Embed code copied to clipboard!");
|
||||
}}
|
||||
EndIcon={CopyIcon}>
|
||||
Copy code
|
||||
</Button>
|
||||
</div>
|
||||
<div className="prose prose-slate max-w-full">
|
||||
<CodeBlock
|
||||
customCodeClass="text-sm h-48 overflow-y-scroll text-sm"
|
||||
language="html"
|
||||
showCopyToClipboard={false}>
|
||||
{iframeCode}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
<div className="mt-2 rounded-md border bg-white p-4">
|
||||
<AdvancedOptionToggle
|
||||
htmlId="enableEmbedMode"
|
||||
isChecked={embedModeEnabled}
|
||||
onToggle={setEmbedModeEnabled}
|
||||
title="Embed Mode"
|
||||
description="Embed your survey with a minimalist design, discarding padding and background."
|
||||
childBorder={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import ChangeSurveyTypeTip from "@/images/tooltips/change-survey-type.mp4";
|
||||
import { CopyIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { CodeBlock } from "@formbricks/ui/CodeBlock";
|
||||
import { OptionsSwitch } from "@formbricks/ui/OptionsSwitch";
|
||||
|
||||
export const WebsiteTab = ({ surveyUrl, environmentId }) => {
|
||||
const [selectedTab, setSelectedTab] = useState("static");
|
||||
|
||||
return (
|
||||
<div className="flex h-full grow flex-col">
|
||||
<OptionsSwitch
|
||||
options={[
|
||||
{ value: "static", label: "Static (iframe)" },
|
||||
{ value: "popup", label: "Dynamic (Pop-up)" },
|
||||
]}
|
||||
currentOption={selectedTab}
|
||||
handleOptionChange={(value) => setSelectedTab(value)}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
{selectedTab === "static" ? (
|
||||
<StaticTab surveyUrl={surveyUrl} />
|
||||
) : (
|
||||
<PopupTab environmentId={environmentId} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StaticTab = ({ surveyUrl }) => {
|
||||
const [embedModeEnabled, setEmbedModeEnabled] = useState(false);
|
||||
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
|
||||
<iframe
|
||||
src="${surveyUrl}${embedModeEnabled ? "?embed=true" : ""}"
|
||||
frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">
|
||||
</iframe>
|
||||
</div>`;
|
||||
|
||||
return (
|
||||
<div className="flex h-full grow flex-col">
|
||||
<div className="flex justify-between">
|
||||
<div></div>
|
||||
<Button
|
||||
title="Embed survey in your website"
|
||||
aria-label="Embed survey in your website"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(iframeCode);
|
||||
toast.success("Embed code copied to clipboard!");
|
||||
}}
|
||||
EndIcon={CopyIcon}>
|
||||
Copy code
|
||||
</Button>
|
||||
</div>
|
||||
<div className="prose prose-slate max-w-full">
|
||||
<CodeBlock
|
||||
customCodeClass="text-sm h-48 overflow-y-scroll text-sm"
|
||||
language="html"
|
||||
showCopyToClipboard={false}>
|
||||
{iframeCode}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
<div className="mt-2 rounded-md border bg-white p-4">
|
||||
<AdvancedOptionToggle
|
||||
htmlId="enableEmbedMode"
|
||||
isChecked={embedModeEnabled}
|
||||
onToggle={setEmbedModeEnabled}
|
||||
title="Embed Mode"
|
||||
description="Embed your survey with a minimalist design, discarding padding and background."
|
||||
childBorder={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PopupTab = ({ environmentId }) => {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-slate-800">How to embed a pop-up survey on your website</p>
|
||||
<ol className="mt-4 list-decimal space-y-2 pl-5 text-sm text-slate-700">
|
||||
<li>
|
||||
Follow these{" "}
|
||||
<Link
|
||||
href={`/environments/${environmentId}/product/website-connection`}
|
||||
target="_blank"
|
||||
className="decoration-brand-dark font-medium underline underline-offset-2">
|
||||
setup instructions
|
||||
</Link>{" "}
|
||||
to connect your website with Formbricks
|
||||
</li>
|
||||
<li>
|
||||
Make sure the survey type is set to <b>Website survey</b>
|
||||
</li>
|
||||
<li>Dfine when and where the survey should pop up</li>
|
||||
</ol>
|
||||
<div className="mt-4">
|
||||
<video autoPlay loop muted className="w-full rounded-xl border border-slate-200">
|
||||
<source src={ChangeSurveyTypeTip} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,8 +4,8 @@ import { z } from "zod";
|
||||
|
||||
const VerificationPageSchema = z.string().email();
|
||||
|
||||
const Page = (params) => {
|
||||
const email = params.searchParams.email;
|
||||
const Page = ({ searchParams }) => {
|
||||
const email = searchParams.email;
|
||||
try {
|
||||
const parsedEmail = VerificationPageSchema.parse(email).toLowerCase();
|
||||
return (
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
// DEPRECATED
|
||||
// Storing actions on the server is deprecated and no longer supported.
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
export const POST = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
@@ -1,88 +0,0 @@
|
||||
// Deprecated since 2024-04-13
|
||||
// last supported js version 1.6.5
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { z } from "zod";
|
||||
import { getAttributesByUserId, updateAttributes } from "@formbricks/lib/attribute/service";
|
||||
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
|
||||
import { ZAttributes } from "@formbricks/types/attributes";
|
||||
|
||||
interface Context {
|
||||
params: {
|
||||
userId: string;
|
||||
environmentId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
export const POST = async (req: Request, context: Context): Promise<Response> => {
|
||||
try {
|
||||
const { userId, environmentId } = context.params;
|
||||
const jsonInput = await req.json();
|
||||
|
||||
// transform all attributes to string if attributes are present
|
||||
if (jsonInput.attributes) {
|
||||
for (const key in jsonInput.attributes) {
|
||||
jsonInput.attributes[key] = String(jsonInput.attributes[key]);
|
||||
}
|
||||
}
|
||||
|
||||
// validate using zod
|
||||
const inputValidation = z.object({ attributes: ZAttributes }).safeParse(jsonInput);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// remove userId from attributes because it is not allowed to be updated
|
||||
const { userId: userIdAttr, ...updatedAttributes } = inputValidation.data.attributes;
|
||||
|
||||
let person = await getPersonByUserId(environmentId, userId);
|
||||
|
||||
if (!person) {
|
||||
// return responses.notFoundResponse("PersonByUserId", userId, true);
|
||||
// HOTFIX: create person if not found to work around caching issue
|
||||
person = await createPerson(environmentId, userId);
|
||||
}
|
||||
|
||||
const oldAttributes = await getAttributesByUserId(environmentId, userId);
|
||||
|
||||
let isUpToDate = true;
|
||||
for (const key in updatedAttributes) {
|
||||
if (updatedAttributes[key] !== oldAttributes[key]) {
|
||||
isUpToDate = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isUpToDate) {
|
||||
return responses.successResponse(
|
||||
{
|
||||
changed: false,
|
||||
message: "No updates were necessary; the person is already up to date.",
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
await updateAttributes(person.id, updatedAttributes);
|
||||
|
||||
return responses.successResponse(
|
||||
{
|
||||
changed: true,
|
||||
message: "The person was successfully updated.",
|
||||
},
|
||||
true
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return responses.internalServerErrorResponse(`Unable to complete request: ${error.message}`, true);
|
||||
}
|
||||
};
|
||||
@@ -1,89 +0,0 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { getActionClasses } from "@formbricks/lib/actionClass/service";
|
||||
import { updateAttributes } from "@formbricks/lib/attribute/service";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { personCache } from "@formbricks/lib/person/cache";
|
||||
import { getPerson } from "@formbricks/lib/person/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { getSyncSurveys } from "@formbricks/lib/survey/service";
|
||||
import { ZJsPeopleAttributeInput } from "@formbricks/types/js";
|
||||
|
||||
interface Context {
|
||||
params: {
|
||||
userId: string;
|
||||
environmentId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
export const POST = async (req: Request, context: Context): Promise<Response> => {
|
||||
try {
|
||||
const { userId, environmentId } = context.params;
|
||||
const personId = userId; // legacy workaround for formbricks-js 1.2.0 & 1.2.1
|
||||
const jsonInput = await req.json();
|
||||
|
||||
// validate using zod
|
||||
const inputValidation = ZJsPeopleAttributeInput.safeParse(jsonInput);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const { key, value } = inputValidation.data;
|
||||
|
||||
const person = await getPerson(personId);
|
||||
|
||||
if (!person) {
|
||||
return responses.notFoundResponse("Person", personId, true);
|
||||
}
|
||||
|
||||
await updateAttributes(personId, { [key]: value });
|
||||
|
||||
personCache.revalidate({
|
||||
id: personId,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
surveyCache.revalidate({
|
||||
environmentId,
|
||||
});
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const [surveys, noCodeActionClasses, product] = await Promise.all([
|
||||
getSyncSurveys(environmentId, person.id),
|
||||
getActionClasses(environmentId),
|
||||
getProductByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
// return state
|
||||
const state = {
|
||||
person: { id: person.id, userId: person.userId },
|
||||
surveys,
|
||||
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
|
||||
product,
|
||||
};
|
||||
|
||||
return responses.successResponse({ ...state }, true);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return responses.internalServerErrorResponse(`Unable to complete request: ${error.message}`, true);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { actionClassCache } from "@formbricks/lib/actionClass/cache";
|
||||
import { getActionClasses } from "@formbricks/lib/actionClass/service";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { environmentCache } from "@formbricks/lib/environment/cache";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { organizationCache } from "@formbricks/lib/organization/cache";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@formbricks/lib/organization/service";
|
||||
import {
|
||||
capturePosthogEnvironmentEvent,
|
||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
||||
} from "@formbricks/lib/posthogServer";
|
||||
import { productCache } from "@formbricks/lib/product/cache";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TJsEnvironmentState } from "@formbricks/types/js";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param environmentId
|
||||
* @returns The environment state
|
||||
* @throws ResourceNotFoundError if the environment or organization does not exist
|
||||
* @throws InvalidInputError if the channel is not "app"
|
||||
*/
|
||||
export const getEnvironmentState = async (
|
||||
environmentId: string
|
||||
): Promise<{ state: TJsEnvironmentState["data"]; revalidateEnvironment?: boolean }> =>
|
||||
cache(
|
||||
async () => {
|
||||
let revalidateEnvironment = false;
|
||||
const [environment, organization, product] = await Promise.all([
|
||||
getEnvironment(environmentId),
|
||||
getOrganizationByEnvironmentId(environmentId),
|
||||
getProductByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
if (!environment) {
|
||||
throw new ResourceNotFoundError("environment", environmentId);
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("organization", null);
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
throw new ResourceNotFoundError("product", null);
|
||||
}
|
||||
|
||||
if (product.config.channel && product.config.channel !== "app") {
|
||||
throw new InvalidInputError("Invalid channel");
|
||||
}
|
||||
|
||||
if (!environment.appSetupCompleted) {
|
||||
await Promise.all([
|
||||
prisma.environment.update({
|
||||
where: {
|
||||
id: environmentId,
|
||||
},
|
||||
data: { appSetupCompleted: true },
|
||||
}),
|
||||
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
|
||||
]);
|
||||
|
||||
revalidateEnvironment = true;
|
||||
}
|
||||
|
||||
// check if MAU limit is reached
|
||||
let isMonthlyResponsesLimitReached = false;
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
|
||||
|
||||
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||
isMonthlyResponsesLimitReached =
|
||||
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
||||
}
|
||||
|
||||
if (isMonthlyResponsesLimitReached) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
monthly: {
|
||||
miu: organization.billing.limits.monthly.miu,
|
||||
responses: organization.billing.limits.monthly.responses,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Error sending plan limits reached event to Posthog: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
const [surveys, actionClasses] = await Promise.all([
|
||||
getSurveys(environmentId),
|
||||
getActionClasses(environmentId),
|
||||
]);
|
||||
|
||||
const filteredSurveys = surveys.filter(
|
||||
(survey) => survey.type === "app" && survey.status === "inProgress"
|
||||
);
|
||||
|
||||
const state: TJsEnvironmentState["data"] = {
|
||||
surveys: !isMonthlyResponsesLimitReached ? filteredSurveys : [],
|
||||
actionClasses,
|
||||
product,
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
revalidateEnvironment,
|
||||
};
|
||||
},
|
||||
[`environmentState-app-${environmentId}`],
|
||||
{
|
||||
...(IS_FORMBRICKS_CLOUD && { revalidate: 24 * 60 * 60 }),
|
||||
tags: [
|
||||
environmentCache.tag.byId(environmentId),
|
||||
organizationCache.tag.byEnvironmentId(environmentId),
|
||||
productCache.tag.byEnvironmentId(environmentId),
|
||||
surveyCache.tag.byEnvironmentId(environmentId),
|
||||
actionClassCache.tag.byEnvironmentId(environmentId),
|
||||
],
|
||||
}
|
||||
)();
|
||||
@@ -0,0 +1,64 @@
|
||||
import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/app/environment/lib/environmentState";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { NextRequest } from "next/server";
|
||||
import { environmentCache } from "@formbricks/lib/environment/cache";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZJsSyncInput } from "@formbricks/types/js";
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
export const GET = async (
|
||||
_: NextRequest,
|
||||
{
|
||||
params,
|
||||
}: {
|
||||
params: {
|
||||
environmentId: string;
|
||||
};
|
||||
}
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
// validate using zod
|
||||
const inputValidation = ZJsSyncInput.safeParse({
|
||||
environmentId: params.environmentId,
|
||||
});
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const environmentState = await getEnvironmentState(params.environmentId);
|
||||
|
||||
if (environmentState.revalidateEnvironment) {
|
||||
environmentCache.revalidate({
|
||||
id: inputValidation.data.environmentId,
|
||||
productId: environmentState.state.product.id,
|
||||
});
|
||||
}
|
||||
|
||||
return responses.successResponse(
|
||||
environmentState.state,
|
||||
true,
|
||||
"public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600"
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof ResourceNotFoundError) {
|
||||
return responses.notFoundResponse(err.resourceType, err.resourceId);
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
return responses.internalServerErrorResponse(err.message, true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return responses.internalServerErrorResponse("Unable to handle the request: " + error.message, true);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
import { getPersonSegmentIds } from "@/app/api/v1/client/[environmentId]/app/people/[userId]/lib/segments";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { attributeCache } from "@formbricks/lib/attribute/cache";
|
||||
import { getAttributesByUserId } from "@formbricks/lib/attribute/service";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { displayCache } from "@formbricks/lib/display/cache";
|
||||
import { getDisplaysByUserId } from "@formbricks/lib/display/service";
|
||||
import { environmentCache } from "@formbricks/lib/environment/cache";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { organizationCache } from "@formbricks/lib/organization/cache";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@formbricks/lib/organization/service";
|
||||
import { personCache } from "@formbricks/lib/person/cache";
|
||||
import { getIsPersonMonthlyActive, getPersonByUserId } from "@formbricks/lib/person/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer";
|
||||
import { responseCache } from "@formbricks/lib/response/cache";
|
||||
import { getResponsesByUserId } from "@formbricks/lib/response/service";
|
||||
import { segmentCache } from "@formbricks/lib/segment/cache";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TJsPersonState } from "@formbricks/types/js";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param environmentId - The environment id
|
||||
* @param userId - The user id
|
||||
* @param device - The device type
|
||||
* @returns The person state
|
||||
* @throws {ValidationError} - If the input is invalid
|
||||
* @throws {ResourceNotFoundError} - If the environment or organization is not found
|
||||
* @throws {OperationNotAllowedError} - If the MAU limit is reached and the person has not been active this month
|
||||
*/
|
||||
export const getPersonState = async ({
|
||||
environmentId,
|
||||
userId,
|
||||
device,
|
||||
}: {
|
||||
environmentId: string;
|
||||
userId: string;
|
||||
device: "phone" | "desktop";
|
||||
}): Promise<{ state: TJsPersonState["data"]; revalidateProps?: { personId: string; revalidate: boolean } }> =>
|
||||
cache(
|
||||
async () => {
|
||||
let revalidatePerson = false;
|
||||
const environment = await getEnvironment(environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new ResourceNotFoundError(`environment`, environmentId);
|
||||
}
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError(`organization`, environmentId);
|
||||
}
|
||||
|
||||
let isMauLimitReached = false;
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
const currentMau = await getMonthlyActiveOrganizationPeopleCount(organization.id);
|
||||
const monthlyMiuLimit = organization.billing.limits.monthly.miu;
|
||||
|
||||
isMauLimitReached = monthlyMiuLimit !== null && currentMau >= monthlyMiuLimit;
|
||||
}
|
||||
|
||||
let person = await getPersonByUserId(environmentId, userId);
|
||||
|
||||
if (isMauLimitReached) {
|
||||
// MAU limit reached: check if person has been active this month; only continue if person has been active
|
||||
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
monthly: {
|
||||
miu: organization.billing.limits.monthly.miu,
|
||||
responses: organization.billing.limits.monthly.responses,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Error sending plan limits reached event to Posthog: ${err}`);
|
||||
}
|
||||
|
||||
const errorMessage = `Monthly Active Users limit in the current plan is reached in ${environmentId}`;
|
||||
if (!person) {
|
||||
// if it's a new person and MAU limit is reached, throw an error
|
||||
throw new OperationNotAllowedError(errorMessage);
|
||||
}
|
||||
|
||||
// check if person has been active this month
|
||||
const isPersonMonthlyActive = await getIsPersonMonthlyActive(person.id);
|
||||
if (!isPersonMonthlyActive) {
|
||||
throw new OperationNotAllowedError(errorMessage);
|
||||
}
|
||||
} else {
|
||||
// MAU limit not reached: create person if not exists
|
||||
if (!person) {
|
||||
person = await prisma.person.create({
|
||||
data: {
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePerson = true;
|
||||
}
|
||||
}
|
||||
|
||||
const personResponses = await getResponsesByUserId(environmentId, userId);
|
||||
const personDisplays = await getDisplaysByUserId(environmentId, userId);
|
||||
const segments = await getPersonSegmentIds(environmentId, person, device);
|
||||
const attributes = await getAttributesByUserId(environmentId, userId);
|
||||
|
||||
// If the person exists, return the persons's state
|
||||
const userState: TJsPersonState["data"] = {
|
||||
userId: person.userId,
|
||||
segments,
|
||||
displays:
|
||||
personDisplays?.map((display) => ({ surveyId: display.surveyId, createdAt: display.createdAt })) ??
|
||||
[],
|
||||
responses: personResponses?.map((response) => response.surveyId) ?? [],
|
||||
attributes,
|
||||
lastDisplayAt:
|
||||
personDisplays.length > 0
|
||||
? personDisplays.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0].createdAt
|
||||
: null,
|
||||
};
|
||||
|
||||
return {
|
||||
state: userState,
|
||||
revalidateProps: revalidatePerson ? { personId: person.id, revalidate: true } : undefined,
|
||||
};
|
||||
},
|
||||
[`personState-${environmentId}-${userId}-${device}`],
|
||||
{
|
||||
...(IS_FORMBRICKS_CLOUD && { revalidate: 24 * 60 * 60 }),
|
||||
tags: [
|
||||
environmentCache.tag.byId(environmentId),
|
||||
organizationCache.tag.byEnvironmentId(environmentId),
|
||||
personCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
|
||||
attributeCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
|
||||
displayCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
|
||||
responseCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
|
||||
segmentCache.tag.byEnvironmentId(environmentId),
|
||||
],
|
||||
}
|
||||
)();
|
||||
@@ -0,0 +1,55 @@
|
||||
import { attributeCache } from "@formbricks/lib/attribute/cache";
|
||||
import { getAttributes } from "@formbricks/lib/attribute/service";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { segmentCache } from "@formbricks/lib/segment/cache";
|
||||
import { evaluateSegment, getSegments } from "@formbricks/lib/segment/service";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TPerson, ZPerson } from "@formbricks/types/people";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
|
||||
export const getPersonSegmentIds = (
|
||||
environmentId: string,
|
||||
person: TPerson,
|
||||
deviceType: "phone" | "desktop"
|
||||
): Promise<string[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId], [person, ZPerson]);
|
||||
|
||||
const segments = await getSegments(environmentId);
|
||||
|
||||
// fast path; if there are no segments, return an empty array
|
||||
if (!segments) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const attributes = await getAttributes(person.id);
|
||||
|
||||
const personSegments: TSegment[] = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
const isIncluded = await evaluateSegment(
|
||||
{
|
||||
attributes,
|
||||
actionIds: [],
|
||||
deviceType,
|
||||
environmentId,
|
||||
personId: person.id,
|
||||
userId: person.userId,
|
||||
},
|
||||
segment.filters
|
||||
);
|
||||
|
||||
if (isIncluded) {
|
||||
personSegments.push(segment);
|
||||
}
|
||||
}
|
||||
|
||||
return personSegments.map((segment) => segment.id);
|
||||
},
|
||||
[`getPersonSegmentIds-${environmentId}-${person.id}-${deviceType}`],
|
||||
{
|
||||
tags: [segmentCache.tag.byEnvironmentId(environmentId), attributeCache.tag.byPersonId(person.id)],
|
||||
}
|
||||
)();
|
||||
@@ -0,0 +1,68 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { NextRequest, userAgent } from "next/server";
|
||||
import { personCache } from "@formbricks/lib/person/cache";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZJsPersonIdentifyInput } from "@formbricks/types/js";
|
||||
import { getPersonState } from "./lib/personState";
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
export const GET = async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: { environmentId: string; userId: string } }
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const { environmentId, userId } = params;
|
||||
|
||||
// Validate input
|
||||
const syncInputValidation = ZJsPersonIdentifyInput.safeParse({
|
||||
environmentId,
|
||||
userId,
|
||||
});
|
||||
if (!syncInputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(syncInputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const { device } = userAgent(request);
|
||||
const deviceType = device ? "phone" : "desktop";
|
||||
|
||||
try {
|
||||
const personState = await getPersonState({
|
||||
environmentId,
|
||||
userId,
|
||||
device: deviceType,
|
||||
});
|
||||
|
||||
if (personState.revalidateProps?.revalidate) {
|
||||
personCache.revalidate({
|
||||
environmentId,
|
||||
userId,
|
||||
id: personState.revalidateProps.personId,
|
||||
});
|
||||
}
|
||||
|
||||
return responses.successResponse(
|
||||
personState.state,
|
||||
true,
|
||||
"public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600"
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof ResourceNotFoundError) {
|
||||
return responses.notFoundResponse(err.resourceType, err.resourceId);
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
return responses.internalServerErrorResponse(err.message ?? "Unable to fetch person state", true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return responses.internalServerErrorResponse(`Unable to complete response: ${error.message}`, true);
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
replaceAttributeRecall,
|
||||
replaceAttributeRecallInLegacySurveys,
|
||||
} from "@/app/api/v1/client/[environmentId]/app/sync/lib/utils";
|
||||
import { replaceAttributeRecall } from "@/app/api/v1/client/[environmentId]/app/sync/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { NextRequest, userAgent } from "next/server";
|
||||
@@ -22,8 +19,6 @@ import {
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
|
||||
import { getSyncSurveys } from "@formbricks/lib/survey/service";
|
||||
import { transformToLegacySurvey } from "@formbricks/lib/survey/utils";
|
||||
import { isVersionGreaterThanOrEqualTo } from "@formbricks/lib/utils/version";
|
||||
import { TJsAppStateSync, ZJsPeopleUserIdInput } from "@formbricks/types/js";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
@@ -44,7 +39,6 @@ export const GET = async (
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const { device } = userAgent(request);
|
||||
const version = request.nextUrl.searchParams.get("version");
|
||||
|
||||
// validate using zod
|
||||
const inputValidation = ZJsPeopleUserIdInput.safeParse({
|
||||
@@ -168,9 +162,7 @@ export const GET = async (
|
||||
}
|
||||
|
||||
const [surveys, actionClasses] = await Promise.all([
|
||||
getSyncSurveys(environmentId, person.id, device.type === "mobile" ? "phone" : "desktop", {
|
||||
version: version ?? undefined,
|
||||
}),
|
||||
getSyncSurveys(environmentId, person.id, device.type === "mobile" ? "phone" : "desktop"),
|
||||
getActionClasses(environmentId),
|
||||
]);
|
||||
|
||||
@@ -187,7 +179,6 @@ export const GET = async (
|
||||
};
|
||||
const attributes = await getAttributes(person.id);
|
||||
const language = attributes["language"];
|
||||
const noCodeActionClasses = actionClasses.filter((actionClass) => actionClass.type === "noCode");
|
||||
|
||||
// Scenario 1: Multi language and updated trigger action classes supported.
|
||||
// Use the surveys as they are.
|
||||
@@ -203,30 +194,6 @@ export const GET = async (
|
||||
product: updatedProduct,
|
||||
};
|
||||
|
||||
// Backwards compatibility for versions less than 2.0.0 (no multi-language support and updated trigger action classes).
|
||||
if (!isVersionGreaterThanOrEqualTo(version ?? "", "2.0.0")) {
|
||||
// Scenario 2: Multi language and updated trigger action classes not supported
|
||||
// Convert to legacy surveys with default language
|
||||
// convert triggers to array of actionClasses Names
|
||||
transformedSurveys = await Promise.all(
|
||||
surveys.map((survey) => {
|
||||
const languageCode = "default";
|
||||
return transformToLegacySurvey(survey as TSurvey, languageCode);
|
||||
})
|
||||
);
|
||||
|
||||
const legacyState: any = {
|
||||
surveys: !isMonthlyResponsesLimitReached
|
||||
? transformedSurveys.map((survey) => replaceAttributeRecallInLegacySurveys(survey, attributes))
|
||||
: [],
|
||||
person,
|
||||
noCodeActionClasses,
|
||||
language,
|
||||
product: updatedProduct,
|
||||
};
|
||||
return responses.successResponse({ ...legacyState }, true);
|
||||
}
|
||||
|
||||
return responses.successResponse({ ...state }, true);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@@ -53,29 +53,3 @@ export const replaceAttributeRecall = (survey: TSurvey, attributes: TAttributes)
|
||||
|
||||
return surveyTemp;
|
||||
};
|
||||
|
||||
export const replaceAttributeRecallInLegacySurveys = (survey: any, attributes: TAttributes): any => {
|
||||
const surveyTemp = structuredClone(survey);
|
||||
surveyTemp.questions.forEach((question) => {
|
||||
if (question.headline.includes("recall:")) {
|
||||
question.headline = parseRecallInfo(question.headline, attributes);
|
||||
}
|
||||
if (question.subheader && question.subheader.includes("recall:")) {
|
||||
question.subheader = parseRecallInfo(question.subheader, attributes);
|
||||
}
|
||||
});
|
||||
if (surveyTemp.welcomeCard.enabled && surveyTemp.welcomeCard.headline) {
|
||||
if (surveyTemp.welcomeCard.headline && surveyTemp.welcomeCard.headline.includes("recall:")) {
|
||||
surveyTemp.welcomeCard.headline = parseRecallInfo(surveyTemp.welcomeCard.headline, attributes);
|
||||
}
|
||||
}
|
||||
if (surveyTemp.thankYouCard.enabled && surveyTemp.thankYouCard.headline) {
|
||||
if (surveyTemp.thankYouCard.headline && surveyTemp.thankYouCard.headline.includes("recall:")) {
|
||||
surveyTemp.thankYouCard.headline = parseRecallInfo(surveyTemp.thankYouCard.headline, attributes);
|
||||
if (surveyTemp.thankYouCard.subheader && surveyTemp.thankYouCard.subheader.includes("recall:")) {
|
||||
surveyTemp.thankYouCard.subheader = parseRecallInfo(surveyTemp.thankYouCard.subheader, attributes);
|
||||
}
|
||||
}
|
||||
}
|
||||
return surveyTemp;
|
||||
};
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { updateDisplay } from "@formbricks/lib/display/service";
|
||||
import { ZDisplayUpdateInput } from "@formbricks/types/displays";
|
||||
|
||||
interface Context {
|
||||
params: {
|
||||
displayId: string;
|
||||
environmentId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
export const PUT = async (request: Request, context: Context): Promise<Response> => {
|
||||
const { displayId, environmentId } = context.params;
|
||||
const jsonInput = await request.json();
|
||||
const inputValidation = ZDisplayUpdateInput.safeParse({
|
||||
...jsonInput,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await updateDisplay(displayId, inputValidation.data);
|
||||
return responses.successResponse({}, true);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return responses.internalServerErrorResponse(error.message, true);
|
||||
}
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { NextRequest } from "next/server";
|
||||
import { getAttributesByUserId, updateAttributes } from "@formbricks/lib/attribute/service";
|
||||
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZJsPeopleUpdateAttributeInput } from "@formbricks/types/js";
|
||||
|
||||
export const OPTIONS = async () => {
|
||||
@@ -81,6 +82,10 @@ export const PUT = async (
|
||||
return responses.forbiddenResponse(err.message || "Forbidden", true, { ignore: true });
|
||||
}
|
||||
|
||||
if (err instanceof ResourceNotFoundError) {
|
||||
return responses.notFoundResponse(err.resourceType, err.resourceId, true);
|
||||
}
|
||||
|
||||
return responses.internalServerErrorResponse("Something went wrong", true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { actionClassCache } from "@formbricks/lib/actionClass/cache";
|
||||
import { getActionClasses } from "@formbricks/lib/actionClass/service";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { environmentCache } from "@formbricks/lib/environment/cache";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { organizationCache } from "@formbricks/lib/organization/cache";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@formbricks/lib/organization/service";
|
||||
import {
|
||||
capturePosthogEnvironmentEvent,
|
||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
||||
} from "@formbricks/lib/posthogServer";
|
||||
import { productCache } from "@formbricks/lib/product/cache";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TJsEnvironmentState } from "@formbricks/types/js";
|
||||
|
||||
/**
|
||||
* Get the environment state
|
||||
* @param environmentId
|
||||
* @returns The environment state
|
||||
* @throws ResourceNotFoundError if the organization, environment or product is not found
|
||||
* @throws InvalidInputError if the product channel is not website
|
||||
*/
|
||||
export const getEnvironmentState = async (
|
||||
environmentId: string
|
||||
): Promise<{ state: TJsEnvironmentState["data"]; revalidateEnvironment?: boolean }> =>
|
||||
cache(
|
||||
async () => {
|
||||
let revalidateEnvironment = false;
|
||||
const [environment, organization, product] = await Promise.all([
|
||||
getEnvironment(environmentId),
|
||||
getOrganizationByEnvironmentId(environmentId),
|
||||
getProductByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
if (!environment) {
|
||||
throw new ResourceNotFoundError("environment", environmentId);
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("organization", null);
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
throw new ResourceNotFoundError("product", null);
|
||||
}
|
||||
|
||||
if (product.config.channel && product.config.channel !== "website") {
|
||||
throw new InvalidInputError("Product channel is not website");
|
||||
}
|
||||
|
||||
// check if response limit is reached
|
||||
let isWebsiteSurveyResponseLimitReached = false;
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
|
||||
|
||||
isWebsiteSurveyResponseLimitReached =
|
||||
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
||||
|
||||
if (isWebsiteSurveyResponseLimitReached) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: { monthly: { responses: monthlyResponseLimit, miu: null } },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error sending plan limits reached event to Posthog: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!environment?.websiteSetupCompleted) {
|
||||
await Promise.all([
|
||||
await prisma.environment.update({
|
||||
where: {
|
||||
id: environmentId,
|
||||
},
|
||||
data: { websiteSetupCompleted: true },
|
||||
}),
|
||||
capturePosthogEnvironmentEvent(environmentId, "website setup completed"),
|
||||
]);
|
||||
|
||||
revalidateEnvironment = true;
|
||||
}
|
||||
|
||||
const [surveys, actionClasses] = await Promise.all([
|
||||
getSurveys(environmentId),
|
||||
getActionClasses(environmentId),
|
||||
]);
|
||||
|
||||
// Common filter condition for selecting surveys that are in progress, are of type 'website' and have no active segment filtering.
|
||||
const filteredSurveys = surveys.filter(
|
||||
(survey) => survey.status === "inProgress" && survey.type === "website"
|
||||
);
|
||||
|
||||
const state: TJsEnvironmentState["data"] = {
|
||||
surveys: filteredSurveys,
|
||||
actionClasses,
|
||||
product,
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
revalidateEnvironment,
|
||||
};
|
||||
},
|
||||
[`environmentState-website-${environmentId}`],
|
||||
{
|
||||
...(IS_FORMBRICKS_CLOUD && { revalidate: 24 * 60 * 60 }),
|
||||
tags: [
|
||||
environmentCache.tag.byId(environmentId),
|
||||
organizationCache.tag.byEnvironmentId(environmentId),
|
||||
productCache.tag.byEnvironmentId(environmentId),
|
||||
surveyCache.tag.byEnvironmentId(environmentId),
|
||||
actionClassCache.tag.byEnvironmentId(environmentId),
|
||||
],
|
||||
}
|
||||
)();
|
||||
@@ -0,0 +1,59 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { NextRequest } from "next/server";
|
||||
import { environmentCache } from "@formbricks/lib/environment/cache";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZJsSyncInput } from "@formbricks/types/js";
|
||||
import { getEnvironmentState } from "./lib/environmentState";
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
export const GET = async (
|
||||
_: NextRequest,
|
||||
{ params }: { params: { environmentId: string } }
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const syncInputValidation = ZJsSyncInput.safeParse({
|
||||
environmentId: params.environmentId,
|
||||
});
|
||||
|
||||
if (!syncInputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(syncInputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const { environmentId } = syncInputValidation.data;
|
||||
|
||||
try {
|
||||
const environmentState = await getEnvironmentState(environmentId);
|
||||
|
||||
if (environmentState.revalidateEnvironment) {
|
||||
environmentCache.revalidate({
|
||||
id: syncInputValidation.data.environmentId,
|
||||
productId: environmentState.state.product.id,
|
||||
});
|
||||
}
|
||||
|
||||
return responses.successResponse(
|
||||
environmentState.state,
|
||||
true,
|
||||
"public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600"
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof ResourceNotFoundError) {
|
||||
return responses.notFoundResponse(err.resourceType, err.resourceId);
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
return responses.internalServerErrorResponse(err.message ?? "Unable to complete response", true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return responses.internalServerErrorResponse(`Unable to complete response: ${error.message}`, true);
|
||||
}
|
||||
};
|
||||
@@ -1,175 +0,0 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { NextRequest } from "next/server";
|
||||
import { getActionClasses } from "@formbricks/lib/actionClass/service";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@formbricks/lib/organization/service";
|
||||
import {
|
||||
capturePosthogEnvironmentEvent,
|
||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
||||
} from "@formbricks/lib/posthogServer";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { transformToLegacySurvey } from "@formbricks/lib/survey/utils";
|
||||
import { isVersionGreaterThanOrEqualTo } from "@formbricks/lib/utils/version";
|
||||
import { TJsWebsiteStateSync, ZJsWebsiteSyncInput } from "@formbricks/types/js";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
export const GET = async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: { environmentId: string } }
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const version =
|
||||
searchParams.get("version") === "undefined" || searchParams.get("version") === null
|
||||
? undefined
|
||||
: searchParams.get("version");
|
||||
const syncInputValidation = ZJsWebsiteSyncInput.safeParse({
|
||||
environmentId: params.environmentId,
|
||||
});
|
||||
|
||||
if (!syncInputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(syncInputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const { environmentId } = syncInputValidation.data;
|
||||
|
||||
const [environment, organization, product] = await Promise.all([
|
||||
getEnvironment(environmentId),
|
||||
getOrganizationByEnvironmentId(environmentId),
|
||||
getProductByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("Organization does not exist");
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
throw new Error("Environment does not exist");
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
if (product.config.channel && product.config.channel !== "website") {
|
||||
return responses.forbiddenResponse("Product channel is not website", true);
|
||||
}
|
||||
|
||||
// check if response limit is reached
|
||||
let isWebsiteSurveyResponseLimitReached = false;
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
|
||||
|
||||
isWebsiteSurveyResponseLimitReached =
|
||||
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
||||
|
||||
if (isWebsiteSurveyResponseLimitReached) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: { monthly: { responses: monthlyResponseLimit, miu: null } },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error sending plan limits reached event to Posthog: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// temporary remove the example survey creation to avoid caching issue with multiple example surveys
|
||||
/* if (!environment?.websiteSetupCompleted) {
|
||||
const exampleTrigger = await getActionClassByEnvironmentIdAndName(environmentId, "New Session");
|
||||
if (!exampleTrigger) {
|
||||
throw new Error("Example trigger not found");
|
||||
}
|
||||
const firstSurvey = getExampleWebsiteSurveyTemplate(WEBAPP_URL, exampleTrigger);
|
||||
await createSurvey(environmentId, firstSurvey);
|
||||
await updateEnvironment(environment.id, { websiteSetupCompleted: true });
|
||||
} */
|
||||
|
||||
if (!environment?.websiteSetupCompleted) {
|
||||
await Promise.all([
|
||||
updateEnvironment(environment.id, { websiteSetupCompleted: true }),
|
||||
capturePosthogEnvironmentEvent(environmentId, "website setup completed"),
|
||||
]);
|
||||
}
|
||||
|
||||
const [surveys, actionClasses] = await Promise.all([
|
||||
getSurveys(environmentId),
|
||||
getActionClasses(environmentId),
|
||||
]);
|
||||
|
||||
// Common filter condition for selecting surveys that are in progress, are of type 'website' and have no active segment filtering.
|
||||
const filteredSurveys = surveys.filter(
|
||||
(survey) => survey.status === "inProgress" && survey.type === "website"
|
||||
// TODO: Find out if this required anymore. Most likely not.
|
||||
// && (!survey.segment || survey.segment.filters.length === 0)
|
||||
);
|
||||
|
||||
const updatedProduct: any = {
|
||||
...product,
|
||||
brandColor: product.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
|
||||
...(product.styling.highlightBorderColor?.light && {
|
||||
highlightBorderColor: product.styling.highlightBorderColor.light,
|
||||
}),
|
||||
};
|
||||
|
||||
const noCodeActionClasses = actionClasses.filter((actionClass) => actionClass.type === "noCode");
|
||||
|
||||
// Define 'transformedSurveys' which can be an array of either TLegacySurvey or TSurvey.
|
||||
let transformedSurveys: TSurvey[] = filteredSurveys;
|
||||
let state: TJsWebsiteStateSync = {
|
||||
surveys: !isWebsiteSurveyResponseLimitReached ? transformedSurveys : [],
|
||||
actionClasses,
|
||||
product: updatedProduct,
|
||||
};
|
||||
|
||||
// Backwards compatibility for versions less than 2.0.0 (no multi-language support and updated trigger action classes).
|
||||
if (!isVersionGreaterThanOrEqualTo(version ?? "", "2.0.0")) {
|
||||
// Scenario 2: Multi language and updated trigger action classes not supported
|
||||
// Convert to legacy surveys with default language
|
||||
// convert triggers to array of actionClasses Names
|
||||
transformedSurveys = await Promise.all(
|
||||
filteredSurveys.map((survey) => {
|
||||
const languageCode = "default";
|
||||
return transformToLegacySurvey(survey, languageCode);
|
||||
})
|
||||
);
|
||||
|
||||
const legacyState: any = {
|
||||
surveys: isWebsiteSurveyResponseLimitReached ? [] : transformedSurveys,
|
||||
noCodeActionClasses,
|
||||
product: updatedProduct,
|
||||
};
|
||||
return responses.successResponse(
|
||||
{ ...legacyState },
|
||||
true,
|
||||
"public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600"
|
||||
);
|
||||
}
|
||||
|
||||
return responses.successResponse(
|
||||
{ ...state },
|
||||
true,
|
||||
"public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600"
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return responses.internalServerErrorResponse(`Unable to complete response: ${error.message}`, true);
|
||||
}
|
||||
};
|
||||
@@ -106,7 +106,7 @@ const methodNotAllowedResponse = (
|
||||
|
||||
const notFoundResponse = (
|
||||
resourceType: string,
|
||||
resourceId: string,
|
||||
resourceId: string | null,
|
||||
cors: boolean = false,
|
||||
cache: string = "private, no-store"
|
||||
) => {
|
||||
|
||||
@@ -39,7 +39,7 @@ const Page = async () => {
|
||||
|
||||
if (!environment) {
|
||||
console.error("Failed to get first environment of user");
|
||||
return redirect(`/organizations/${userOrganizations[0].id}/products/new/channel`);
|
||||
return redirect(`/organizations/${userOrganizations[0].id}/products/new/mode`);
|
||||
}
|
||||
|
||||
return redirect(`/environments/${environment.id}`);
|
||||
|
||||
@@ -279,6 +279,7 @@ export const LinkSurvey = ({
|
||||
source: sourceParam || "",
|
||||
},
|
||||
variables: responseUpdate.variables,
|
||||
displayId: surveyState.displayId,
|
||||
...(Object.keys(hiddenFieldsRecord).length > 0 && { hiddenFields: hiddenFieldsRecord }),
|
||||
});
|
||||
}}
|
||||
|
||||
BIN
apps/web/images/tooltips/change-survey-type-app.mp4
Normal file
BIN
apps/web/images/tooltips/change-survey-type-app.mp4
Normal file
Binary file not shown.
BIN
apps/web/images/tooltips/change-survey-type.mp4
Normal file
BIN
apps/web/images/tooltips/change-survey-type.mp4
Normal file
Binary file not shown.
@@ -1,4 +1,3 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
import createJiti from "jiti";
|
||||
import { createRequire } from "node:module";
|
||||
@@ -62,18 +61,6 @@ const nextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/api/v1/client/:environmentId/in-app/sync",
|
||||
destination: "/api/v1/client/:environmentId/website/sync",
|
||||
},
|
||||
{
|
||||
source: "/api/v1/client/:environmentId/in-app/sync/:userId",
|
||||
destination: "/api/v1/client/:environmentId/app/sync/:userId",
|
||||
},
|
||||
];
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
@@ -103,6 +90,22 @@ const nextConfig = {
|
||||
},
|
||||
];
|
||||
},
|
||||
webpack: (config) => {
|
||||
config.module.rules.push({
|
||||
test: /\.(mp4|webm|ogg|swf|ogv)$/,
|
||||
use: [
|
||||
{
|
||||
loader: "file-loader",
|
||||
options: {
|
||||
publicPath: "/_next/static/videos/",
|
||||
outputPath: "static/videos/",
|
||||
name: "[name].[hash].[ext]",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return config;
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"encoding": "^0.1.13",
|
||||
"file-loader": "^6.2.0",
|
||||
"framer-motion": "11.3.28",
|
||||
"googleapis": "^140.0.1",
|
||||
"jiti": "^1.21.6",
|
||||
|
||||
@@ -59,7 +59,7 @@ test.describe("JS Package Test", async () => {
|
||||
// Formbricks In App Sync has happened
|
||||
const syncApi = await page.waitForResponse(
|
||||
(response) => {
|
||||
return response.url().includes("/app/sync");
|
||||
return response.url().includes("/app/environment");
|
||||
},
|
||||
{
|
||||
timeout: 120000,
|
||||
|
||||
@@ -9,10 +9,11 @@ test.describe("Onboarding Flow Test", async () => {
|
||||
const user = await users.create({ withoutProduct: true });
|
||||
await user.login();
|
||||
|
||||
await page.waitForURL(/\/organizations\/[^/]+\/products\/new\/channel/);
|
||||
await page.waitForURL(/\/organizations\/[^/]+\/products\/new\/mode/);
|
||||
|
||||
await page.getByRole("button", { name: "Formbricks Surveys Multi-" }).click();
|
||||
await page.getByRole("button", { name: "Anywhere online Link" }).click();
|
||||
await page.getByRole("button", { name: "B2B and B2C E-Commerce" }).click();
|
||||
// await page.getByRole("button", { name: "B2B and B2C E-Commerce" }).click();
|
||||
await page.getByPlaceholder("e.g. Formbricks").click();
|
||||
await page.getByPlaceholder("e.g. Formbricks").fill(productName);
|
||||
await page.locator("form").filter({ hasText: "Brand colorMatch the main" }).getByRole("button").click();
|
||||
@@ -25,10 +26,11 @@ test.describe("Onboarding Flow Test", async () => {
|
||||
const user = await users.create({ withoutProduct: true });
|
||||
await user.login();
|
||||
|
||||
await page.waitForURL(/\/organizations\/[^/]+\/products\/new\/channel/);
|
||||
await page.waitForURL(/\/organizations\/[^/]+\/products\/new\/mode/);
|
||||
|
||||
await page.getByRole("button", { name: "Formbricks Surveys Multi-" }).click();
|
||||
await page.getByRole("button", { name: "Enrich user profiles App with" }).click();
|
||||
await page.getByRole("button", { name: "B2B and B2C E-Commerce" }).click();
|
||||
// await page.getByRole("button", { name: "B2B and B2C E-Commerce" }).click();
|
||||
await page.getByPlaceholder("e.g. Formbricks").click();
|
||||
await page.getByPlaceholder("e.g. Formbricks").fill(productName);
|
||||
await page.locator("form").filter({ hasText: "Brand colorMatch the main" }).getByRole("button").click();
|
||||
@@ -40,3 +42,23 @@ test.describe("Onboarding Flow Test", async () => {
|
||||
await expect(page.getByText(productName)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("CX Onboarding", async () => {
|
||||
test("first survey creation", async ({ page, users }) => {
|
||||
const user = await users.create({ withoutProduct: true });
|
||||
await user.login();
|
||||
|
||||
await page.waitForURL(/\/organizations\/[^/]+\/products\/new\/mode/);
|
||||
await page.getByRole("button", { name: "Formbricks CX Surveys and" }).click();
|
||||
|
||||
await page.getByPlaceholder("e.g. Formbricks").click();
|
||||
await page.getByPlaceholder("e.g. Formbricks").fill(productName);
|
||||
await page.locator("form").filter({ hasText: "Brand colorMatch the main" }).getByRole("button").click();
|
||||
await page.getByRole("button", { name: "NPS Implement proven best" }).click();
|
||||
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/edit(\?.*)mode=cx$/);
|
||||
await page.getByRole("button", { name: "Save & Close" }).click();
|
||||
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,7 +79,9 @@ export const finishOnboarding = async (
|
||||
page: Page,
|
||||
ProductChannel: TProductConfigChannel = "website"
|
||||
): Promise<void> => {
|
||||
await page.waitForURL(/\/organizations\/[^/]+\/products\/new\/channel/);
|
||||
await page.waitForURL(/\/organizations\/[^/]+\/products\/new\/mode/);
|
||||
|
||||
await page.getByRole("button", { name: "Formbricks Surveys Multi-" }).click();
|
||||
|
||||
if (ProductChannel === "website") {
|
||||
await page.getByRole("button", { name: "Built for scale Public website" }).click();
|
||||
@@ -89,7 +91,7 @@ export const finishOnboarding = async (
|
||||
await page.getByRole("button", { name: "Anywhere online Link" }).click();
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: "Proven methods SaaS" }).click();
|
||||
// await page.getByRole("button", { name: "Proven methods SaaS" }).click();
|
||||
await page.getByPlaceholder("e.g. Formbricks").click();
|
||||
await page.getByPlaceholder("e.g. Formbricks").fill("My Product");
|
||||
await page.locator("form").filter({ hasText: "Brand colorMatch the main" }).getByRole("button").click();
|
||||
|
||||
@@ -37,18 +37,6 @@ The API client is now ready to be used across your project. It can be used to in
|
||||
});
|
||||
```
|
||||
|
||||
- Update a Display
|
||||
|
||||
```ts
|
||||
await api.client.display.update(
|
||||
displayId: "<your-display-id>",
|
||||
{
|
||||
userId: "<your-user-id>", // optional
|
||||
responseId: "<your-response-id>", // optional
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
- Create a Response
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type TDisplayCreateInput, type TDisplayUpdateInput } from "@formbricks/types/displays";
|
||||
import { type TDisplayCreateInput } from "@formbricks/types/displays";
|
||||
import { type Result } from "@formbricks/types/error-handlers";
|
||||
import { type NetworkError } from "@formbricks/types/errors";
|
||||
import { makeRequest } from "../../utils/make-request";
|
||||
@@ -17,16 +17,4 @@ export class DisplayAPI {
|
||||
): Promise<Result<{ id: string }, NetworkError | Error>> {
|
||||
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/displays`, "POST", displayInput);
|
||||
}
|
||||
|
||||
async update(
|
||||
displayId: string,
|
||||
displayInput: Omit<TDisplayUpdateInput, "environmentId">
|
||||
): Promise<Result<object, NetworkError | Error>> {
|
||||
return makeRequest(
|
||||
this.apiHost,
|
||||
`/api/v1/client/${this.environmentId}/displays/${displayId}`,
|
||||
"PUT",
|
||||
displayInput
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/* eslint-disable no-console -- logging is allowed in migration scripts */
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const TRANSACTION_TIMEOUT = 30 * 60 * 1000; // 30 minutes in milliseconds
|
||||
|
||||
async function runMigration(): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
console.log("Starting data migration...");
|
||||
|
||||
await prisma.$transaction(
|
||||
async (transactionPrisma) => {
|
||||
// Step 1: Use raw SQL to bulk update responses where responseId is not null in displays
|
||||
console.log("Running bulk update for responses with valid responseId...");
|
||||
|
||||
const rawQueryResult = await transactionPrisma.$executeRaw`
|
||||
WITH updated_displays AS (
|
||||
UPDATE public."Response" r
|
||||
SET "displayId" = d.id
|
||||
FROM public."Display" d
|
||||
WHERE r.id = d."responseId"
|
||||
RETURNING d.id
|
||||
)
|
||||
UPDATE public."Display"
|
||||
SET "responseId" = NULL
|
||||
WHERE id IN (SELECT id FROM updated_displays);
|
||||
`;
|
||||
|
||||
console.log("Bulk update completed!");
|
||||
|
||||
// Step 2: Handle the case where a display has a responseId but the corresponding response does not exist
|
||||
console.log("Handling displays where the responseId exists but the response is missing...");
|
||||
|
||||
// Find displays where responseId is not null but the corresponding response does not exist
|
||||
const displaysWithMissingResponses = await transactionPrisma.display.findMany({
|
||||
where: {
|
||||
responseId: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
responseId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const responseIds = displaysWithMissingResponses
|
||||
.map((display) => display.responseId)
|
||||
.filter((id): id is string => id !== null);
|
||||
|
||||
// Check which of the responseIds actually exist in the responses table
|
||||
const existingResponses = await transactionPrisma.response.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: responseIds,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const existingResponseIds = new Set(existingResponses.map((response) => response.id));
|
||||
|
||||
// Find displays where the responseId does not exist in the responses table
|
||||
const displayIdsToDelete = displaysWithMissingResponses
|
||||
.filter((display) => !existingResponseIds.has(display.responseId as unknown as string))
|
||||
.map((display) => display.id);
|
||||
|
||||
if (displayIdsToDelete.length > 0) {
|
||||
console.log(
|
||||
`Deleting ${displayIdsToDelete.length.toString()} displays where the response is missing...`
|
||||
);
|
||||
|
||||
await transactionPrisma.display.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: displayIdsToDelete,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Displays where the response was missing have been deleted.");
|
||||
console.log("Data migration completed.");
|
||||
console.log(`Affected rows: ${String(rawQueryResult + displayIdsToDelete.length)}`);
|
||||
},
|
||||
{
|
||||
timeout: TRANSACTION_TIMEOUT,
|
||||
}
|
||||
);
|
||||
|
||||
const endTime = Date.now();
|
||||
console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toFixed(2)}s`);
|
||||
}
|
||||
|
||||
function handleError(error: unknown): void {
|
||||
console.error("An error occurred during migration:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function handleDisconnectError(): void {
|
||||
console.error("Failed to disconnect Prisma client");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
runMigration()
|
||||
.catch(handleError)
|
||||
.finally(() => {
|
||||
prisma.$disconnect().catch(handleDisconnectError);
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[displayId]` on the table `Response` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Response" ADD COLUMN "displayId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Response_displayId_key" ON "Response"("displayId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Response" ADD CONSTRAINT "Response_displayId_fkey" FOREIGN KEY ("displayId") REFERENCES "Display"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -48,6 +48,7 @@
|
||||
"data-migration:v2.4": "pnpm data-migration:segments-cleanup && pnpm data-migration:multiple-endings && pnpm data-migration:simplified-email-verification && pnpm data-migration:fix-logic-end-destination",
|
||||
"data-migration:remove-dismissed-value-inconsistency": "ts-node ./data-migrations/20240807120500_cta_consent_dismissed_inconsistency/data-migration.ts",
|
||||
"data-migration:v2.5": "pnpm data-migration:remove-dismissed-value-inconsistency",
|
||||
"data-migration:add-display-id-to-response": "ts-node ./data-migrations/20240905120500_refactor_display_response_relationship/data-migration.ts",
|
||||
"data-migration:advanced-logic": "ts-node ./data-migrations/20240828122408_advanced_logic_editor/data-migration.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -135,6 +135,8 @@ model Response {
|
||||
// singleUseId, used to prevent multiple responses
|
||||
singleUseId String?
|
||||
language String?
|
||||
displayId String? @unique
|
||||
display Display? @relation(fields: [displayId], references: [id])
|
||||
|
||||
@@unique([surveyId, singleUseId])
|
||||
@@index([surveyId, createdAt]) // to determine monthly response count
|
||||
@@ -201,8 +203,9 @@ model Display {
|
||||
surveyId String
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: Cascade)
|
||||
personId String?
|
||||
responseId String? @unique
|
||||
responseId String? @unique //deprecated
|
||||
status DisplayStatus?
|
||||
response Response?
|
||||
|
||||
@@index([surveyId])
|
||||
@@index([personId, createdAt])
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { TJsTrackProperties } from "@formbricks/types/js";
|
||||
import { InvalidCodeError, NetworkError, Result, err, okVoid } from "../../shared/errors";
|
||||
import { Logger } from "../../shared/logger";
|
||||
import { getIsDebug } from "../../shared/utils";
|
||||
import { AppConfig } from "./config";
|
||||
import { sync } from "./sync";
|
||||
import { triggerSurvey } from "./widget";
|
||||
|
||||
const logger = Logger.getInstance();
|
||||
@@ -15,31 +13,11 @@ export const trackAction = async (
|
||||
properties?: TJsTrackProperties
|
||||
): Promise<Result<void, NetworkError>> => {
|
||||
const aliasName = alias || name;
|
||||
const { userId } = appConfig.get();
|
||||
|
||||
if (userId) {
|
||||
// we skip the resync on a new action since this leads to too many requests if the user has a lot of actions
|
||||
// also this always leads to a second sync call on the `New Session` action
|
||||
// when debug: sync after every action for testing purposes
|
||||
if (getIsDebug()) {
|
||||
logger.debug(`Resync after action "${aliasName} in debug mode"`);
|
||||
await sync(
|
||||
{
|
||||
environmentId: appConfig.get().environmentId,
|
||||
apiHost: appConfig.get().apiHost,
|
||||
userId,
|
||||
attributes: appConfig.get().state.attributes,
|
||||
},
|
||||
true,
|
||||
appConfig
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`Formbricks: Action "${aliasName}" tracked`);
|
||||
|
||||
// get a list of surveys that are collecting insights
|
||||
const activeSurveys = appConfig.get().state?.surveys;
|
||||
const activeSurveys = appConfig.get().filteredSurveys;
|
||||
|
||||
if (!!activeSurveys && activeSurveys.length > 0) {
|
||||
for (const survey of activeSurveys) {
|
||||
@@ -60,9 +38,7 @@ export const trackCodeAction = (
|
||||
code: string,
|
||||
properties?: TJsTrackProperties
|
||||
): Promise<Result<void, NetworkError>> | Result<void, InvalidCodeError> => {
|
||||
const {
|
||||
state: { actionClasses = [] },
|
||||
} = appConfig.get();
|
||||
const actionClasses = appConfig.get().environmentState.data.actionClasses;
|
||||
|
||||
const codeActionClasses = actionClasses.filter((action) => action.type === "code");
|
||||
const action = codeActionClasses.find((action) => action.key === code);
|
||||
|
||||
@@ -2,13 +2,37 @@ import { FormbricksAPI } from "@formbricks/api";
|
||||
import { TAttributes } from "@formbricks/types/attributes";
|
||||
import { MissingPersonError, NetworkError, Result, err, ok, okVoid } from "../../shared/errors";
|
||||
import { Logger } from "../../shared/logger";
|
||||
import { fetchPersonState } from "../../shared/personState";
|
||||
import { filterSurveys } from "../../shared/utils";
|
||||
import { AppConfig } from "./config";
|
||||
|
||||
const appConfig = AppConfig.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
|
||||
export const updateAttribute = async (key: string, value: string): Promise<Result<void, NetworkError>> => {
|
||||
const { apiHost, environmentId, userId } = appConfig.get();
|
||||
export const updateAttribute = async (
|
||||
key: string,
|
||||
value: string | number
|
||||
): Promise<
|
||||
Result<
|
||||
{
|
||||
changed: boolean;
|
||||
message: string;
|
||||
},
|
||||
Error | NetworkError
|
||||
>
|
||||
> => {
|
||||
const { apiHost, environmentId } = appConfig.get();
|
||||
const userId = appConfig.get().personState.data.userId;
|
||||
|
||||
if (!userId) {
|
||||
return err({
|
||||
code: "network_error",
|
||||
status: 500,
|
||||
message: "Missing userId",
|
||||
url: `${apiHost}/api/v1/client/${environmentId}/people/${userId}/attributes`,
|
||||
responseMessage: "Missing userId",
|
||||
});
|
||||
}
|
||||
|
||||
const api = new FormbricksAPI({
|
||||
apiHost,
|
||||
@@ -21,7 +45,13 @@ export const updateAttribute = async (key: string, value: string): Promise<Resul
|
||||
// @ts-expect-error
|
||||
if (res.error.details?.ignore) {
|
||||
logger.error(res.error.message ?? `Error updating person with userId ${userId}`);
|
||||
return okVoid();
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
changed: false,
|
||||
message: res.error.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
return err({
|
||||
code: "network_error",
|
||||
@@ -35,23 +65,35 @@ export const updateAttribute = async (key: string, value: string): Promise<Resul
|
||||
|
||||
if (res.data.changed) {
|
||||
logger.debug("Attribute updated in Formbricks");
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
changed: true,
|
||||
message: "Attribute updated in Formbricks",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return okVoid();
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
changed: false,
|
||||
message: "Attribute not updated in Formbricks",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const updateAttributes = async (
|
||||
apiHost: string,
|
||||
environmentId: string,
|
||||
userId: string,
|
||||
attributes: TAttributes,
|
||||
appConfig: AppConfig
|
||||
attributes: TAttributes
|
||||
): Promise<Result<TAttributes, NetworkError>> => {
|
||||
// clean attributes and remove existing attributes if config already exists
|
||||
const updatedAttributes = { ...attributes };
|
||||
|
||||
try {
|
||||
const existingAttributes = appConfig.get()?.state?.attributes;
|
||||
const existingAttributes = appConfig.get().personState.data.attributes;
|
||||
if (existingAttributes) {
|
||||
for (const [key, value] of Object.entries(existingAttributes)) {
|
||||
if (updatedAttributes[key] === value) {
|
||||
@@ -97,10 +139,11 @@ export const updateAttributes = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const isExistingAttribute = (key: string, value: string, appConfig: AppConfig): boolean => {
|
||||
if (appConfig.get().state.attributes[key] === value) {
|
||||
export const isExistingAttribute = (key: string, value: string): boolean => {
|
||||
if (appConfig.get().personState.data.attributes[key] === value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -113,9 +156,18 @@ export const setAttributeInApp = async (
|
||||
return okVoid();
|
||||
}
|
||||
|
||||
const userId = appConfig.get().personState.data.userId;
|
||||
|
||||
if (!userId) {
|
||||
return err({
|
||||
code: "missing_person",
|
||||
message: "Missing userId",
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug("Setting attribute: " + key + " to value: " + value);
|
||||
// check if attribute already exists with this value
|
||||
if (isExistingAttribute(key, value.toString(), appConfig)) {
|
||||
if (isExistingAttribute(key, value.toString())) {
|
||||
logger.debug("Attribute already set to this value. Skipping update.");
|
||||
return okVoid();
|
||||
}
|
||||
@@ -123,22 +175,27 @@ export const setAttributeInApp = async (
|
||||
const result = await updateAttribute(key, value.toString());
|
||||
|
||||
if (result.ok) {
|
||||
// udpdate attribute in config
|
||||
appConfig.update({
|
||||
environmentId: appConfig.get().environmentId,
|
||||
apiHost: appConfig.get().apiHost,
|
||||
userId: appConfig.get().userId,
|
||||
state: {
|
||||
...appConfig.get().state,
|
||||
attributes: {
|
||||
...appConfig.get().state.attributes,
|
||||
[key]: value.toString(),
|
||||
if (result.value.changed) {
|
||||
const personState = await fetchPersonState(
|
||||
{
|
||||
apiHost: appConfig.get().apiHost,
|
||||
environmentId: appConfig.get().environmentId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
expiresAt: appConfig.get().expiresAt,
|
||||
});
|
||||
true
|
||||
);
|
||||
|
||||
const filteredSurveys = filterSurveys(appConfig.get().environmentState, personState);
|
||||
|
||||
appConfig.update({
|
||||
...appConfig.get(),
|
||||
personState,
|
||||
filteredSurveys,
|
||||
});
|
||||
}
|
||||
|
||||
return okVoid();
|
||||
}
|
||||
|
||||
return err(result.error);
|
||||
return err(result.error as NetworkError);
|
||||
};
|
||||
|
||||
@@ -1,89 +1,67 @@
|
||||
import { TJSAppConfig, TJsAppConfigUpdateInput } from "@formbricks/types/js";
|
||||
import { TJsConfig, TJsConfigUpdateInput } from "@formbricks/types/js";
|
||||
import { APP_SURVEYS_LOCAL_STORAGE_KEY } from "../../shared/constants";
|
||||
import { Result, err, ok, wrapThrows } from "../../shared/errors";
|
||||
|
||||
export interface StorageHandler {
|
||||
getItem(key: string): Promise<string | null>;
|
||||
setItem(key: string, value: string): Promise<void>;
|
||||
removeItem(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
// LocalStorage implementation - default
|
||||
class LocalStorage implements StorageHandler {
|
||||
async getItem(key: string): Promise<string | null> {
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
|
||||
async setItem(key: string, value: string): Promise<void> {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
async removeItem(key: string): Promise<void> {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
export class AppConfig {
|
||||
private static instance: AppConfig | undefined;
|
||||
private config: TJSAppConfig | null = null;
|
||||
private storageHandler: StorageHandler;
|
||||
private storageKey: string;
|
||||
private config: TJsConfig | null = null;
|
||||
|
||||
private constructor(
|
||||
storageHandler: StorageHandler = new LocalStorage(),
|
||||
storageKey: string = APP_SURVEYS_LOCAL_STORAGE_KEY
|
||||
) {
|
||||
this.storageHandler = storageHandler;
|
||||
this.storageKey = storageKey;
|
||||
private constructor() {
|
||||
const savedConfig = this.loadFromLocalStorage();
|
||||
|
||||
this.loadFromStorage().then((res) => {
|
||||
if (res.ok) {
|
||||
this.config = res.value;
|
||||
}
|
||||
});
|
||||
if (savedConfig.ok) {
|
||||
this.config = savedConfig.value;
|
||||
}
|
||||
}
|
||||
|
||||
static getInstance(storageHandler?: StorageHandler, storageKey?: string): AppConfig {
|
||||
static getInstance(): AppConfig {
|
||||
if (!AppConfig.instance) {
|
||||
AppConfig.instance = new AppConfig(storageHandler, storageKey);
|
||||
AppConfig.instance = new AppConfig();
|
||||
}
|
||||
return AppConfig.instance;
|
||||
}
|
||||
|
||||
public update(newConfig: TJsAppConfigUpdateInput): void {
|
||||
public update(newConfig: TJsConfigUpdateInput): void {
|
||||
if (newConfig) {
|
||||
this.config = {
|
||||
...this.config,
|
||||
...newConfig,
|
||||
status: newConfig.status || "success",
|
||||
status: {
|
||||
value: newConfig.status?.value || "success",
|
||||
expiresAt: newConfig.status?.expiresAt || null,
|
||||
},
|
||||
};
|
||||
|
||||
this.saveToStorage();
|
||||
}
|
||||
}
|
||||
|
||||
public get(): TJSAppConfig {
|
||||
public get(): TJsConfig {
|
||||
if (!this.config) {
|
||||
throw new Error("config is null, maybe the init function was not called?");
|
||||
}
|
||||
return this.config;
|
||||
}
|
||||
|
||||
public async loadFromStorage(): Promise<Result<TJSAppConfig, Error>> {
|
||||
try {
|
||||
const savedConfig = await this.storageHandler.getItem(this.storageKey);
|
||||
public loadFromLocalStorage(): Result<TJsConfig, Error> {
|
||||
if (typeof window !== "undefined") {
|
||||
const savedConfig = localStorage.getItem(APP_SURVEYS_LOCAL_STORAGE_KEY);
|
||||
if (savedConfig) {
|
||||
const parsedConfig = JSON.parse(savedConfig) as TJSAppConfig;
|
||||
// TODO: validate config
|
||||
// This is a hack to get around the fact that we don't have a proper
|
||||
// way to validate the config yet.
|
||||
const parsedConfig = JSON.parse(savedConfig) as TJsConfig;
|
||||
|
||||
// check if the config has expired
|
||||
if (parsedConfig.expiresAt && new Date(parsedConfig.expiresAt) <= new Date()) {
|
||||
if (
|
||||
parsedConfig.environmentState?.expiresAt &&
|
||||
new Date(parsedConfig.environmentState.expiresAt) <= new Date()
|
||||
) {
|
||||
return err(new Error("Config in local storage has expired"));
|
||||
}
|
||||
|
||||
return ok(parsedConfig);
|
||||
}
|
||||
} catch (e) {
|
||||
return err(new Error("No or invalid config in local storage"));
|
||||
}
|
||||
|
||||
return err(new Error("No or invalid config in local storage"));
|
||||
@@ -91,7 +69,7 @@ export class AppConfig {
|
||||
|
||||
private async saveToStorage(): Promise<Result<Promise<void>, Error>> {
|
||||
return wrapThrows(async () => {
|
||||
await this.storageHandler.setItem(this.storageKey, JSON.stringify(this.config));
|
||||
await localStorage.setItem(APP_SURVEYS_LOCAL_STORAGE_KEY, JSON.stringify(this.config));
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -100,9 +78,8 @@ export class AppConfig {
|
||||
public async resetConfig(): Promise<Result<Promise<void>, Error>> {
|
||||
this.config = null;
|
||||
|
||||
// return wrapThrows(() => localStorage.removeItem(IN_APP_LOCAL_STORAGE_KEY))();
|
||||
return wrapThrows(async () => {
|
||||
await this.storageHandler.removeItem(this.storageKey);
|
||||
localStorage.removeItem(APP_SURVEYS_LOCAL_STORAGE_KEY);
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
import {
|
||||
addEnvironmentStateExpiryCheckListener,
|
||||
clearEnvironmentStateExpiryCheckListener,
|
||||
} from "../../shared/environmentState";
|
||||
import {
|
||||
addPersonStateExpiryCheckListener,
|
||||
clearPersonStateExpiryCheckListener,
|
||||
} from "../../shared/personState";
|
||||
import {
|
||||
addClickEventListener,
|
||||
addExitIntentListener,
|
||||
@@ -9,13 +17,12 @@ import {
|
||||
removeScrollDepthListener,
|
||||
} from "../lib/noCodeActions";
|
||||
import { AppConfig } from "./config";
|
||||
import { addExpiryCheckListener, removeExpiryCheckListener } from "./sync";
|
||||
|
||||
let areRemoveEventListenersAdded = false;
|
||||
const appConfig = AppConfig.getInstance();
|
||||
|
||||
export const addEventListeners = (): void => {
|
||||
addExpiryCheckListener(appConfig);
|
||||
export const addEventListeners = (config: AppConfig): void => {
|
||||
addEnvironmentStateExpiryCheckListener("app", config);
|
||||
addPersonStateExpiryCheckListener(config);
|
||||
addPageUrlEventListeners();
|
||||
addClickEventListener();
|
||||
addExitIntentListener();
|
||||
@@ -25,7 +32,8 @@ export const addEventListeners = (): void => {
|
||||
export const addCleanupEventListeners = (): void => {
|
||||
if (areRemoveEventListenersAdded) return;
|
||||
window.addEventListener("beforeunload", () => {
|
||||
removeExpiryCheckListener();
|
||||
clearEnvironmentStateExpiryCheckListener();
|
||||
clearPersonStateExpiryCheckListener();
|
||||
removePageUrlEventListeners();
|
||||
removeClickEventListener();
|
||||
removeExitIntentListener();
|
||||
@@ -37,7 +45,8 @@ export const addCleanupEventListeners = (): void => {
|
||||
export const removeCleanupEventListeners = (): void => {
|
||||
if (!areRemoveEventListenersAdded) return;
|
||||
window.removeEventListener("beforeunload", () => {
|
||||
removeExpiryCheckListener();
|
||||
clearEnvironmentStateExpiryCheckListener();
|
||||
clearPersonStateExpiryCheckListener();
|
||||
removePageUrlEventListeners();
|
||||
removeClickEventListener();
|
||||
removeExitIntentListener();
|
||||
@@ -47,7 +56,8 @@ export const removeCleanupEventListeners = (): void => {
|
||||
};
|
||||
|
||||
export const removeAllEventListeners = (): void => {
|
||||
removeExpiryCheckListener();
|
||||
clearEnvironmentStateExpiryCheckListener();
|
||||
clearPersonStateExpiryCheckListener();
|
||||
removePageUrlEventListeners();
|
||||
removeClickEventListener();
|
||||
removeExitIntentListener();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TAttributes } from "@formbricks/types/attributes";
|
||||
import type { TJSAppConfig, TJsAppConfigInput } from "@formbricks/types/js";
|
||||
import type { TJsAppConfigInput, TJsConfig } from "@formbricks/types/js";
|
||||
import { APP_SURVEYS_LOCAL_STORAGE_KEY } from "../../shared/constants";
|
||||
import { fetchEnvironmentState } from "../../shared/environmentState";
|
||||
import {
|
||||
ErrorHandler,
|
||||
MissingFieldError,
|
||||
@@ -13,16 +14,16 @@ import {
|
||||
wrapThrows,
|
||||
} from "../../shared/errors";
|
||||
import { Logger } from "../../shared/logger";
|
||||
import { getIsDebug } from "../../shared/utils";
|
||||
import { fetchPersonState } from "../../shared/personState";
|
||||
import { filterSurveys, getIsDebug } from "../../shared/utils";
|
||||
import { trackNoCodeAction } from "./actions";
|
||||
import { updateAttributes } from "./attributes";
|
||||
import { AppConfig } from "./config";
|
||||
import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./eventListeners";
|
||||
import { checkPageUrl } from "./noCodeActions";
|
||||
import { sync } from "./sync";
|
||||
import { addWidgetContainer, removeWidgetContainer, setIsSurveyRunning } from "./widget";
|
||||
|
||||
const appConfig = AppConfig.getInstance();
|
||||
const appConfigGlobal = AppConfig.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
|
||||
let isInitialized = false;
|
||||
@@ -31,6 +32,20 @@ export const setIsInitialized = (value: boolean) => {
|
||||
isInitialized = value;
|
||||
};
|
||||
|
||||
const checkForOlderLocalConfig = (): boolean => {
|
||||
const oldConfig = localStorage.getItem(APP_SURVEYS_LOCAL_STORAGE_KEY);
|
||||
|
||||
if (oldConfig) {
|
||||
const parsedOldConfig = JSON.parse(oldConfig);
|
||||
if (parsedOldConfig.state || parsedOldConfig.expiresAt) {
|
||||
// local config follows old structure
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const initialize = async (
|
||||
configInput: TJsAppConfigInput
|
||||
): Promise<Result<void, MissingFieldError | NetworkError | MissingPersonError>> => {
|
||||
@@ -39,31 +54,46 @@ export const initialize = async (
|
||||
logger.configure({ logLevel: "debug" });
|
||||
}
|
||||
|
||||
const isLocalStorageOld = checkForOlderLocalConfig();
|
||||
|
||||
let appConfig = appConfigGlobal;
|
||||
|
||||
if (isLocalStorageOld) {
|
||||
logger.debug("Local config is of an older version");
|
||||
logger.debug("Resetting config");
|
||||
|
||||
appConfig.resetConfig();
|
||||
appConfig = AppConfig.getInstance();
|
||||
}
|
||||
|
||||
if (isInitialized) {
|
||||
logger.debug("Already initialized, skipping initialization.");
|
||||
return okVoid();
|
||||
}
|
||||
|
||||
let existingConfig: TJSAppConfig | undefined;
|
||||
let existingConfig: TJsConfig | undefined;
|
||||
try {
|
||||
existingConfig = appConfig.get();
|
||||
existingConfig = appConfigGlobal.get();
|
||||
logger.debug("Found existing configuration.");
|
||||
} catch (e) {
|
||||
logger.debug("No existing configuration found.");
|
||||
}
|
||||
|
||||
// formbricks is in error state, skip initialization
|
||||
if (existingConfig?.status === "error") {
|
||||
if (existingConfig?.status?.value === "error") {
|
||||
if (isDebug) {
|
||||
logger.debug(
|
||||
"Formbricks is in error state, but debug mode is active. Resetting config and continuing."
|
||||
);
|
||||
appConfig.resetConfig();
|
||||
appConfigGlobal.resetConfig();
|
||||
return okVoid();
|
||||
}
|
||||
|
||||
logger.debug("Formbricks was set to an error state.");
|
||||
if (existingConfig?.expiresAt && new Date(existingConfig.expiresAt) > new Date()) {
|
||||
|
||||
const expiresAt = existingConfig?.status?.expiresAt;
|
||||
|
||||
if (expiresAt && new Date(expiresAt) > new Date()) {
|
||||
logger.debug("Error state is not expired, skipping initialization");
|
||||
return okVoid();
|
||||
} else {
|
||||
@@ -110,8 +140,7 @@ export const initialize = async (
|
||||
configInput.apiHost,
|
||||
configInput.environmentId,
|
||||
configInput.userId,
|
||||
configInput.attributes,
|
||||
appConfig
|
||||
configInput.attributes
|
||||
);
|
||||
if (res.ok !== true) {
|
||||
return err(res.error);
|
||||
@@ -121,50 +150,96 @@ export const initialize = async (
|
||||
|
||||
if (
|
||||
existingConfig &&
|
||||
existingConfig.state &&
|
||||
existingConfig.environmentState &&
|
||||
existingConfig.environmentId === configInput.environmentId &&
|
||||
existingConfig.apiHost === configInput.apiHost &&
|
||||
existingConfig.userId === configInput.userId &&
|
||||
existingConfig.expiresAt // only accept config when they follow new config version with expiresAt
|
||||
existingConfig.personState?.data?.userId === configInput.userId
|
||||
) {
|
||||
logger.debug("Configuration fits init parameters.");
|
||||
if (existingConfig.expiresAt < new Date()) {
|
||||
logger.debug("Configuration expired.");
|
||||
let isEnvironmentStateExpired = false;
|
||||
let isPersonStateExpired = false;
|
||||
|
||||
try {
|
||||
await sync(
|
||||
{
|
||||
if (new Date(existingConfig.environmentState.expiresAt) < new Date()) {
|
||||
logger.debug("Environment state expired. Syncing.");
|
||||
isEnvironmentStateExpired = true;
|
||||
}
|
||||
|
||||
if (existingConfig.personState.expiresAt && new Date(existingConfig.personState.expiresAt) < new Date()) {
|
||||
logger.debug("Person state expired. Syncing.");
|
||||
isPersonStateExpired = true;
|
||||
}
|
||||
|
||||
try {
|
||||
// fetch the environment state (if expired)
|
||||
const environmentState = isEnvironmentStateExpired
|
||||
? await fetchEnvironmentState(
|
||||
{
|
||||
apiHost: configInput.apiHost,
|
||||
environmentId: configInput.environmentId,
|
||||
},
|
||||
"app"
|
||||
)
|
||||
: existingConfig.environmentState;
|
||||
|
||||
// fetch the person state (if expired)
|
||||
const personState = isPersonStateExpired
|
||||
? await fetchPersonState({
|
||||
apiHost: configInput.apiHost,
|
||||
environmentId: configInput.environmentId,
|
||||
userId: configInput.userId,
|
||||
},
|
||||
undefined,
|
||||
appConfig
|
||||
);
|
||||
} catch (e) {
|
||||
putFormbricksInErrorState();
|
||||
}
|
||||
} else {
|
||||
logger.debug("Configuration not expired. Extending expiration.");
|
||||
appConfig.update(existingConfig);
|
||||
})
|
||||
: existingConfig.personState;
|
||||
|
||||
// filter the environment state wrt the person state
|
||||
const filteredSurveys = filterSurveys(environmentState, personState);
|
||||
|
||||
// update the appConfig with the new filtered surveys
|
||||
appConfigGlobal.update({
|
||||
...existingConfig,
|
||||
environmentState,
|
||||
personState,
|
||||
filteredSurveys,
|
||||
});
|
||||
|
||||
const surveyNames = filteredSurveys.map((s) => s.name);
|
||||
logger.debug("Fetched " + surveyNames.length + " surveys during sync: " + surveyNames.join(", "));
|
||||
} catch (e) {
|
||||
putFormbricksInErrorState(appConfig);
|
||||
}
|
||||
} else {
|
||||
logger.debug(
|
||||
"No valid configuration found or it has been expired. Resetting config and creating new one."
|
||||
);
|
||||
appConfig.resetConfig();
|
||||
appConfigGlobal.resetConfig();
|
||||
logger.debug("Syncing.");
|
||||
|
||||
try {
|
||||
await sync(
|
||||
const environmentState = await fetchEnvironmentState(
|
||||
{
|
||||
apiHost: configInput.apiHost,
|
||||
environmentId: configInput.environmentId,
|
||||
},
|
||||
"app",
|
||||
false
|
||||
);
|
||||
const personState = await fetchPersonState(
|
||||
{
|
||||
apiHost: configInput.apiHost,
|
||||
environmentId: configInput.environmentId,
|
||||
userId: configInput.userId,
|
||||
},
|
||||
undefined,
|
||||
appConfig
|
||||
false
|
||||
);
|
||||
|
||||
const filteredSurveys = filterSurveys(environmentState, personState);
|
||||
|
||||
appConfigGlobal.update({
|
||||
apiHost: configInput.apiHost,
|
||||
environmentId: configInput.environmentId,
|
||||
personState,
|
||||
environmentState,
|
||||
filteredSurveys,
|
||||
});
|
||||
} catch (e) {
|
||||
handleErrorOnFirstInit();
|
||||
}
|
||||
@@ -172,22 +247,26 @@ export const initialize = async (
|
||||
// and track the new session event
|
||||
await trackNoCodeAction("New Session");
|
||||
}
|
||||
|
||||
// update attributes in config
|
||||
if (updatedAttributes && Object.keys(updatedAttributes).length > 0) {
|
||||
appConfig.update({
|
||||
environmentId: appConfig.get().environmentId,
|
||||
apiHost: appConfig.get().apiHost,
|
||||
userId: appConfig.get().userId,
|
||||
state: {
|
||||
...appConfig.get().state,
|
||||
attributes: { ...appConfig.get().state.attributes, ...configInput.attributes },
|
||||
appConfigGlobal.update({
|
||||
...appConfigGlobal.get(),
|
||||
personState: {
|
||||
...appConfigGlobal.get().personState,
|
||||
data: {
|
||||
...appConfigGlobal.get().personState.data,
|
||||
attributes: {
|
||||
...appConfigGlobal.get().personState.data.attributes,
|
||||
...updatedAttributes,
|
||||
},
|
||||
},
|
||||
},
|
||||
expiresAt: appConfig.get().expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug("Adding event listeners");
|
||||
addEventListeners();
|
||||
addEventListeners(appConfigGlobal);
|
||||
addCleanupEventListeners();
|
||||
|
||||
setIsInitialized(true);
|
||||
@@ -199,17 +278,20 @@ export const initialize = async (
|
||||
return okVoid();
|
||||
};
|
||||
|
||||
const handleErrorOnFirstInit = () => {
|
||||
export const handleErrorOnFirstInit = () => {
|
||||
if (getIsDebug()) {
|
||||
logger.debug("Not putting formbricks in error state because debug mode is active (no error state)");
|
||||
return;
|
||||
}
|
||||
|
||||
// put formbricks in error state (by creating a new config) and throw error
|
||||
const initialErrorConfig: Partial<TJSAppConfig> = {
|
||||
status: "error",
|
||||
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
|
||||
const initialErrorConfig: Partial<TJsConfig> = {
|
||||
status: {
|
||||
value: "error",
|
||||
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
|
||||
},
|
||||
};
|
||||
|
||||
// can't use config.update here because the config is not yet initialized
|
||||
wrapThrows(() => localStorage.setItem(APP_SURVEYS_LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig)))();
|
||||
throw new Error("Could not initialize formbricks");
|
||||
@@ -235,7 +317,7 @@ export const deinitalize = (): void => {
|
||||
setIsInitialized(false);
|
||||
};
|
||||
|
||||
export const putFormbricksInErrorState = (): void => {
|
||||
export const putFormbricksInErrorState = (appConfig: AppConfig): void => {
|
||||
if (getIsDebug()) {
|
||||
logger.debug("Not putting formbricks in error state because debug mode is active (no error state)");
|
||||
return;
|
||||
@@ -244,9 +326,11 @@ export const putFormbricksInErrorState = (): void => {
|
||||
logger.debug("Putting formbricks in error state");
|
||||
// change formbricks status to error
|
||||
appConfig.update({
|
||||
...appConfig.get(),
|
||||
status: "error",
|
||||
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
|
||||
...appConfigGlobal.get(),
|
||||
status: {
|
||||
value: "error",
|
||||
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
|
||||
},
|
||||
});
|
||||
deinitalize();
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { evaluateNoCodeConfigClick, handleUrlFilters } from "../../shared/utils"
|
||||
import { trackNoCodeAction } from "./actions";
|
||||
import { AppConfig } from "./config";
|
||||
|
||||
const inAppConfig = AppConfig.getInstance();
|
||||
const appConfig = AppConfig.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
const errorHandler = ErrorHandler.getInstance();
|
||||
|
||||
@@ -17,8 +17,7 @@ let arePageUrlEventListenersAdded = false;
|
||||
|
||||
export const checkPageUrl = async (): Promise<Result<void, NetworkError>> => {
|
||||
logger.debug(`Checking page url: ${window.location.href}`);
|
||||
const { state } = inAppConfig.get();
|
||||
const { actionClasses = [] } = state ?? {};
|
||||
const actionClasses = appConfig.get().environmentState.data.actionClasses;
|
||||
|
||||
const noCodePageViewActionClasses = actionClasses.filter(
|
||||
(action) => action.type === "noCode" && action.noCodeConfig?.type === "pageView"
|
||||
@@ -55,10 +54,11 @@ export const removePageUrlEventListeners = (): void => {
|
||||
let isClickEventListenerAdded = false;
|
||||
|
||||
const checkClickMatch = (event: MouseEvent) => {
|
||||
const { state } = inAppConfig.get();
|
||||
if (!state) return;
|
||||
const { environmentState } = appConfig.get();
|
||||
if (!environmentState) return;
|
||||
|
||||
const { actionClasses = [] } = environmentState.data;
|
||||
|
||||
const { actionClasses = [] } = state;
|
||||
const noCodeClickActionClasses = actionClasses.filter(
|
||||
(action) => action.type === "noCode" && action.noCodeConfig?.type === "click"
|
||||
);
|
||||
@@ -96,8 +96,8 @@ export const removeClickEventListener = (): void => {
|
||||
let isExitIntentListenerAdded = false;
|
||||
|
||||
const checkExitIntent = async (e: MouseEvent) => {
|
||||
const { state } = inAppConfig.get();
|
||||
const { actionClasses = [] } = state ?? {};
|
||||
const { environmentState } = appConfig.get();
|
||||
const { actionClasses = [] } = environmentState.data ?? {};
|
||||
|
||||
const noCodeExitIntentActionClasses = actionClasses.filter(
|
||||
(action) => action.type === "noCode" && action.noCodeConfig?.type === "exitIntent"
|
||||
@@ -148,8 +148,8 @@ const checkScrollDepth = async () => {
|
||||
if (!scrollDepthTriggered && scrollPosition / (bodyHeight - windowSize) >= 0.5) {
|
||||
scrollDepthTriggered = true;
|
||||
|
||||
const { state } = inAppConfig.get();
|
||||
const { actionClasses = [] } = state ?? {};
|
||||
const { environmentState } = appConfig.get();
|
||||
const { actionClasses = [] } = environmentState.data ?? {};
|
||||
|
||||
const noCodefiftyPercentScrollActionClasses = actionClasses.filter(
|
||||
(action) => action.type === "noCode" && action.noCodeConfig?.type === "fiftyPercentScroll"
|
||||
|
||||
@@ -15,11 +15,23 @@ export const logoutPerson = async (): Promise<void> => {
|
||||
export const resetPerson = async (): Promise<Result<void, NetworkError>> => {
|
||||
logger.debug("Resetting state & getting new state from backend");
|
||||
closeSurvey();
|
||||
|
||||
const userId = appConfig.get().personState.data.userId;
|
||||
if (!userId) {
|
||||
return err({
|
||||
code: "network_error",
|
||||
status: 500,
|
||||
message: "Missing userId",
|
||||
url: `${appConfig.get().apiHost}/api/v1/client/${appConfig.get().environmentId}/people/${userId}/attributes`,
|
||||
responseMessage: "Missing userId",
|
||||
});
|
||||
}
|
||||
|
||||
const syncParams = {
|
||||
environmentId: appConfig.get().environmentId,
|
||||
apiHost: appConfig.get().apiHost,
|
||||
userId: appConfig.get().userId,
|
||||
attributes: appConfig.get().state.attributes,
|
||||
userId,
|
||||
attributes: appConfig.get().personState.data.attributes,
|
||||
};
|
||||
await logoutPerson();
|
||||
try {
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { TAttributes } from "@formbricks/types/attributes";
|
||||
import { TJsAppState, TJsAppStateSync, TJsAppSyncParams } from "@formbricks/types/js";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { NetworkError, Result, err, ok } from "../../shared/errors";
|
||||
import { Logger } from "../../shared/logger";
|
||||
import { AppConfig } from "./config";
|
||||
|
||||
const logger = Logger.getInstance();
|
||||
|
||||
let syncIntervalId: number | null = null;
|
||||
|
||||
const syncWithBackend = async (
|
||||
{ apiHost, environmentId, userId }: TJsAppSyncParams,
|
||||
noCache: boolean
|
||||
): Promise<Result<TJsAppStateSync, NetworkError>> => {
|
||||
try {
|
||||
let fetchOptions: RequestInit = {};
|
||||
|
||||
if (noCache) {
|
||||
fetchOptions.cache = "no-cache";
|
||||
logger.debug("No cache option set for sync");
|
||||
}
|
||||
logger.debug("syncing with backend");
|
||||
const url = `${apiHost}/api/v1/client/${environmentId}/app/sync/${userId}?version=2.0.0`;
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
const jsonRes = await response.json();
|
||||
|
||||
return err({
|
||||
code: "network_error",
|
||||
status: response.status,
|
||||
message: "Error syncing with backend",
|
||||
url,
|
||||
responseMessage: jsonRes.message,
|
||||
});
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const { data: state } = data;
|
||||
|
||||
return ok(state as TJsAppStateSync);
|
||||
} catch (e) {
|
||||
return err(e as NetworkError);
|
||||
}
|
||||
};
|
||||
|
||||
export const sync = async (
|
||||
params: TJsAppSyncParams,
|
||||
noCache = false,
|
||||
appConfig: AppConfig
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const syncResult = await syncWithBackend(params, noCache);
|
||||
|
||||
if (syncResult?.ok !== true) {
|
||||
throw syncResult.error;
|
||||
}
|
||||
|
||||
let attributes: TAttributes = params.attributes || {};
|
||||
|
||||
if (syncResult.value.language) {
|
||||
attributes.language = syncResult.value.language;
|
||||
}
|
||||
|
||||
let state: TJsAppState = {
|
||||
surveys: syncResult.value.surveys as TSurvey[],
|
||||
actionClasses: syncResult.value.actionClasses,
|
||||
product: syncResult.value.product,
|
||||
attributes,
|
||||
};
|
||||
|
||||
const surveyNames = state.surveys.map((s) => s.name);
|
||||
logger.debug("Fetched " + surveyNames.length + " surveys during sync: " + surveyNames.join(", "));
|
||||
|
||||
appConfig.update({
|
||||
apiHost: params.apiHost,
|
||||
environmentId: params.environmentId,
|
||||
userId: params.userId,
|
||||
state,
|
||||
expiresAt: new Date(new Date().getTime() + 2 * 60000), // 2 minutes in the future
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error during sync: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const addExpiryCheckListener = (appConfig: AppConfig): void => {
|
||||
const updateInterval = 1000 * 30; // every 30 seconds
|
||||
// add event listener to check sync with backend on regular interval
|
||||
if (typeof window !== "undefined" && syncIntervalId === null) {
|
||||
syncIntervalId = window.setInterval(async () => {
|
||||
try {
|
||||
// check if the config has not expired yet
|
||||
if (appConfig.get().expiresAt && new Date(appConfig.get().expiresAt) >= new Date()) {
|
||||
return;
|
||||
}
|
||||
logger.debug("Config has expired. Starting sync.");
|
||||
await sync(
|
||||
{
|
||||
apiHost: appConfig.get().apiHost,
|
||||
environmentId: appConfig.get().environmentId,
|
||||
userId: appConfig.get().userId,
|
||||
attributes: appConfig.get().state.attributes,
|
||||
},
|
||||
false,
|
||||
appConfig
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(`Error during expiry check: ${e}`);
|
||||
logger.debug("Extending config and try again later.");
|
||||
const existingConfig = appConfig.get();
|
||||
appConfig.update(existingConfig);
|
||||
}
|
||||
}, updateInterval);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeExpiryCheckListener = (): void => {
|
||||
if (typeof window !== "undefined" && syncIntervalId !== null) {
|
||||
window.clearInterval(syncIntervalId);
|
||||
|
||||
syncIntervalId = null;
|
||||
}
|
||||
};
|
||||
@@ -2,27 +2,24 @@ import { FormbricksAPI } from "@formbricks/api";
|
||||
import { ResponseQueue } from "@formbricks/lib/responseQueue";
|
||||
import { SurveyState } from "@formbricks/lib/surveyState";
|
||||
import { getStyling } from "@formbricks/lib/utils/styling";
|
||||
import { TJsTrackProperties } from "@formbricks/types/js";
|
||||
import { TJsFileUploadParams, TJsPersonState, TJsTrackProperties } from "@formbricks/types/js";
|
||||
import { TResponseHiddenFieldValue, TResponseUpdate } from "@formbricks/types/responses";
|
||||
import { TUploadFileConfig } from "@formbricks/types/storage";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { ErrorHandler } from "../../shared/errors";
|
||||
import { Logger } from "../../shared/logger";
|
||||
import {
|
||||
filterSurveys,
|
||||
getDefaultLanguageCode,
|
||||
getLanguageCode,
|
||||
handleHiddenFields,
|
||||
shouldDisplayBasedOnPercentage,
|
||||
} from "../../shared/utils";
|
||||
import { AppConfig } from "./config";
|
||||
import { putFormbricksInErrorState } from "./initialize";
|
||||
import { sync } from "./sync";
|
||||
|
||||
const containerId = "formbricks-app-container";
|
||||
|
||||
const appConfig = AppConfig.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
const errorHandler = ErrorHandler.getInstance();
|
||||
let isSurveyRunning = false;
|
||||
let setIsError = (_: boolean) => {};
|
||||
let setIsResponseSendingFinished = (_: boolean) => {};
|
||||
@@ -68,8 +65,8 @@ const renderWidget = async (
|
||||
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay} seconds.`);
|
||||
}
|
||||
|
||||
const product = appConfig.get().state.product;
|
||||
const attributes = appConfig.get().state.attributes;
|
||||
const { product } = appConfig.get().environmentState.data ?? {};
|
||||
const { attributes } = appConfig.get().personState.data ?? {};
|
||||
|
||||
const isMultiLanguageSurvey = survey.languages.length > 1;
|
||||
let languageCode = "default";
|
||||
@@ -85,7 +82,7 @@ const renderWidget = async (
|
||||
languageCode = displayLanguage;
|
||||
}
|
||||
|
||||
const surveyState = new SurveyState(survey.id, null, null, appConfig.get().userId);
|
||||
const surveyState = new SurveyState(survey.id, null, null, appConfig.get().personState.data.userId);
|
||||
|
||||
const responseQueue = new ResponseQueue(
|
||||
{
|
||||
@@ -124,7 +121,12 @@ const renderWidget = async (
|
||||
setIsResponseSendingFinished = f;
|
||||
},
|
||||
onDisplay: async () => {
|
||||
const { userId } = appConfig.get();
|
||||
const { userId } = appConfig.get().personState.data;
|
||||
|
||||
if (!userId) {
|
||||
logger.debug("User ID not found. Skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
const api = new FormbricksAPI({
|
||||
apiHost: appConfig.get().apiHost,
|
||||
@@ -144,9 +146,39 @@ const renderWidget = async (
|
||||
|
||||
surveyState.updateDisplayId(id);
|
||||
responseQueue.updateSurveyState(surveyState);
|
||||
|
||||
const existingDisplays = appConfig.get().personState.data.displays;
|
||||
const newDisplay = { surveyId: survey.id, createdAt: new Date() };
|
||||
const displays = existingDisplays ? [...existingDisplays, newDisplay] : [newDisplay];
|
||||
const previousConfig = appConfig.get();
|
||||
|
||||
const updatedPersonState: TJsPersonState = {
|
||||
...previousConfig.personState,
|
||||
data: {
|
||||
...previousConfig.personState.data,
|
||||
displays,
|
||||
lastDisplayAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
const filteredSurveys = filterSurveys(previousConfig.environmentState, updatedPersonState);
|
||||
|
||||
appConfig.update({
|
||||
...previousConfig,
|
||||
personState: updatedPersonState,
|
||||
filteredSurveys,
|
||||
});
|
||||
},
|
||||
onResponse: (responseUpdate: TResponseUpdate) => {
|
||||
const { userId } = appConfig.get();
|
||||
const { userId } = appConfig.get().personState.data;
|
||||
|
||||
if (!userId) {
|
||||
logger.debug("User ID not found. Skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
const isNewResponse = surveyState.responseId === null;
|
||||
|
||||
surveyState.updateUserId(userId);
|
||||
|
||||
responseQueue.updateSurveyState(surveyState);
|
||||
@@ -162,13 +194,31 @@ const renderWidget = async (
|
||||
},
|
||||
variables: responseUpdate.variables,
|
||||
hiddenFields,
|
||||
displayId: surveyState.displayId,
|
||||
});
|
||||
|
||||
if (isNewResponse) {
|
||||
const responses = appConfig.get().personState.data.responses;
|
||||
const newPersonState: TJsPersonState = {
|
||||
...appConfig.get().personState,
|
||||
data: {
|
||||
...appConfig.get().personState.data,
|
||||
responses: [...responses, surveyState.surveyId],
|
||||
},
|
||||
};
|
||||
|
||||
const filteredSurveys = filterSurveys(appConfig.get().environmentState, newPersonState);
|
||||
|
||||
appConfig.update({
|
||||
...appConfig.get(),
|
||||
environmentState: appConfig.get().environmentState,
|
||||
personState: newPersonState,
|
||||
filteredSurveys,
|
||||
});
|
||||
}
|
||||
},
|
||||
onClose: closeSurvey,
|
||||
onFileUpload: async (
|
||||
file: { type: string; name: string; base64: string },
|
||||
params: TUploadFileConfig
|
||||
) => {
|
||||
onFileUpload: async (file: TJsFileUploadParams["file"], params: TUploadFileConfig) => {
|
||||
const api = new FormbricksAPI({
|
||||
apiHost: appConfig.get().apiHost,
|
||||
environmentId: appConfig.get().environmentId,
|
||||
@@ -197,23 +247,17 @@ export const closeSurvey = async (): Promise<void> => {
|
||||
removeWidgetContainer();
|
||||
addWidgetContainer();
|
||||
|
||||
// for identified users we sync to get the latest surveys
|
||||
try {
|
||||
await sync(
|
||||
{
|
||||
apiHost: appConfig.get().apiHost,
|
||||
environmentId: appConfig.get().environmentId,
|
||||
userId: appConfig.get().userId,
|
||||
attributes: appConfig.get().state.attributes,
|
||||
},
|
||||
true,
|
||||
appConfig
|
||||
);
|
||||
setIsSurveyRunning(false);
|
||||
} catch (e: any) {
|
||||
errorHandler.handle(e);
|
||||
putFormbricksInErrorState();
|
||||
}
|
||||
const { environmentState, personState } = appConfig.get();
|
||||
const filteredSurveys = filterSurveys(environmentState, personState);
|
||||
|
||||
appConfig.update({
|
||||
...appConfig.get(),
|
||||
environmentState,
|
||||
personState,
|
||||
filteredSurveys,
|
||||
});
|
||||
|
||||
setIsSurveyRunning(false);
|
||||
};
|
||||
|
||||
export const addWidgetContainer = (): void => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user