mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-20 05:49:07 -06:00
feat: Introduce Formbricks CX (#3152)
Co-authored-by: RajuGangitla <gangitlaraju8520@gmail.com> Co-authored-by: Johannes <johannes@formbricks.com> Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com> Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com> Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import {
|
||||
CXQuestionTypes,
|
||||
getQuestionDefaults,
|
||||
questionTypes,
|
||||
universalQuestionPresets,
|
||||
@@ -15,11 +16,14 @@ 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}
|
||||
@@ -41,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" && (
|
||||
|
||||
@@ -4,7 +4,12 @@ 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 { QUESTIONS_ICON_MAP, QUESTIONS_NAME_MAP, getQuestionDefaults } from "@formbricks/lib/utils/questions";
|
||||
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>{" "}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -49,6 +49,7 @@ interface QuestionsViewProps {
|
||||
isFormbricksCloud: boolean;
|
||||
attributeClasses: TAttributeClass[];
|
||||
plan: TOrganizationBillingPlan;
|
||||
isCxMode: boolean;
|
||||
}
|
||||
|
||||
export const QuestionsView = ({
|
||||
@@ -65,6 +66,7 @@ export const QuestionsView = ({
|
||||
isFormbricksCloud,
|
||||
attributeClasses,
|
||||
plan,
|
||||
isCxMode,
|
||||
}: QuestionsViewProps) => {
|
||||
const internalQuestionIdMap = useMemo(() => {
|
||||
return localSurvey.questions.reduce((acc, question) => {
|
||||
@@ -359,18 +361,20 @@ 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
|
||||
id="questions"
|
||||
@@ -393,10 +397,11 @@ 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
|
||||
@@ -427,37 +432,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
|
||||
{/* <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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
@@ -305,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}
|
||||
@@ -350,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}
|
||||
@@ -388,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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -169,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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
"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";
|
||||
@@ -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);
|
||||
@@ -77,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
|
||||
@@ -104,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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
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";
|
||||
@@ -91,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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -41,7 +41,7 @@ export const welcomeCardDefault: TSurveyWelcomeCard = {
|
||||
showResponseCount: false,
|
||||
};
|
||||
|
||||
const surveyDefault: TTemplate["preset"] = {
|
||||
export const surveyDefault: TTemplate["preset"] = {
|
||||
name: "New Survey",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
endings: [getDefaultEndingCard([])],
|
||||
@@ -1472,7 +1472,6 @@ export const templates: TTemplate[] = [
|
||||
|
||||
{
|
||||
name: "Net Promoter Score (NPS)",
|
||||
|
||||
role: "customerSuccess",
|
||||
industries: ["saas", "eCommerce", "other"],
|
||||
channels: ["app", "link", "website"],
|
||||
|
||||
@@ -229,6 +229,18 @@ export const questionTypes: TQuestion[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const CXQuestionTypes = questionTypes.filter((questionType) => {
|
||||
return [
|
||||
TSurveyQuestionTypeEnum.OpenText,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
TSurveyQuestionTypeEnum.Rating,
|
||||
TSurveyQuestionTypeEnum.NPS,
|
||||
TSurveyQuestionTypeEnum.Consent,
|
||||
TSurveyQuestionTypeEnum.CTA,
|
||||
].includes(questionType.id as TSurveyQuestionTypeEnum);
|
||||
});
|
||||
|
||||
export const QUESTIONS_ICON_MAP: Record<TSurveyQuestionTypeEnum, JSX.Element> = questionTypes.reduce(
|
||||
(prev, curr) => ({
|
||||
...prev,
|
||||
@@ -245,6 +257,14 @@ export const QUESTIONS_NAME_MAP = questionTypes.reduce(
|
||||
{}
|
||||
) as Record<TSurveyQuestionTypeEnum, string>;
|
||||
|
||||
export const CX_QUESTIONS_NAME_MAP = CXQuestionTypes.reduce(
|
||||
(prev, curr) => ({
|
||||
...prev,
|
||||
[curr.id]: curr.label,
|
||||
}),
|
||||
{}
|
||||
) as Record<TSurveyQuestionTypeEnum, string>;
|
||||
|
||||
export const universalQuestionPresets = {
|
||||
required: true,
|
||||
};
|
||||
|
||||
@@ -15,6 +15,9 @@ export type TProductConfigIndustry = z.infer<typeof ZProductConfigIndustry>;
|
||||
export const ZProductConfigChannel = z.enum(["link", "app", "website"]).nullable();
|
||||
export type TProductConfigChannel = z.infer<typeof ZProductConfigChannel>;
|
||||
|
||||
export const ZProductMode = z.enum(["surveys", "cx"]);
|
||||
export type TProductMode = z.infer<typeof ZProductMode>;
|
||||
|
||||
export const ZProductConfig = z.object({
|
||||
channel: ZProductConfigChannel,
|
||||
industry: ZProductConfigIndustry,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { ZProductConfigChannel, ZProductConfigIndustry } from "./product";
|
||||
import { ZSurveyEndings, ZSurveyHiddenFields, ZSurveyQuestions, ZSurveyWelcomeCard } from "./surveys/types";
|
||||
import {
|
||||
ZSurveyEndings,
|
||||
ZSurveyHiddenFields,
|
||||
ZSurveyQuestions,
|
||||
ZSurveyStyling,
|
||||
ZSurveyWelcomeCard,
|
||||
} from "./surveys/types";
|
||||
import { ZUserObjective } from "./user";
|
||||
|
||||
export const ZTemplateRole = z.enum(["productManager", "customerSuccess", "marketing", "sales"]);
|
||||
@@ -25,6 +31,15 @@ export const ZTemplate = z.object({
|
||||
|
||||
export type TTemplate = z.infer<typeof ZTemplate>;
|
||||
|
||||
export const ZXMTemplate = z.object({
|
||||
name: z.string(),
|
||||
questions: ZSurveyQuestions,
|
||||
endings: ZSurveyEndings,
|
||||
styling: ZSurveyStyling,
|
||||
});
|
||||
|
||||
export type TXMTemplate = z.infer<typeof ZXMTemplate>;
|
||||
|
||||
export const ZTemplateFilter = z.union([
|
||||
ZProductConfigChannel,
|
||||
ZProductConfigIndustry,
|
||||
|
||||
4
packages/types/video.d.ts
vendored
Normal file
4
packages/types/video.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module "*.mp4" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
@@ -2,14 +2,14 @@ import React from "react";
|
||||
|
||||
interface HeaderProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({ title, subtitle }) => {
|
||||
return (
|
||||
<div className="space-y-8 text-center">
|
||||
<p className="text-4xl font-medium text-slate-800">{title}</p>
|
||||
<p className="text-slate-500">{subtitle}</p>
|
||||
{subtitle && <p className="text-slate-500">{subtitle}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,9 +12,9 @@ interface PathwayOptionProps {
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "rounded-lg border border-slate-200 shadow-card-sm transition-all duration-150",
|
||||
md: "rounded-xl border border-slate-200 shadow-card-md transition-all duration-300",
|
||||
lg: "rounded-2xl border border-slate-200 shadow-card-lg transition-all duration-500",
|
||||
sm: "rounded-lg max-w-xs border border-slate-200 shadow-card-sm transition-all duration-150",
|
||||
md: "rounded-xl max-w-xs border border-slate-200 shadow-card-md transition-all duration-300",
|
||||
lg: "rounded-2xl max-w-sm border border-slate-200 shadow-card-lg transition-all duration-500",
|
||||
};
|
||||
|
||||
export const OptionCard: React.FC<PathwayOptionProps> = ({
|
||||
@@ -35,9 +35,9 @@ export const OptionCard: React.FC<PathwayOptionProps> = ({
|
||||
tabIndex={0}>
|
||||
<div className="space-y-4">
|
||||
{children}
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 text-center">
|
||||
<p className="text-xl font-medium text-slate-800">{title}</p>
|
||||
<p className="text-sm text-slate-500">{description}</p>
|
||||
<p className="text-balance text-sm text-slate-500">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,40 +1,62 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface TOption {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
disabled?: boolean; // Add disabled property to individual options
|
||||
}
|
||||
|
||||
interface OptionsSwitchProps {
|
||||
options: TOption[];
|
||||
currentOption: string | undefined;
|
||||
handleOptionChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const OptionsSwitch = ({
|
||||
options: questionTypes,
|
||||
currentOption,
|
||||
handleOptionChange,
|
||||
disabled = false,
|
||||
}: OptionsSwitchProps) => {
|
||||
const [highlightStyle, setHighlightStyle] = useState({});
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
const activeElement = containerRef.current.querySelector(`[data-value="${currentOption}"]`);
|
||||
if (activeElement) {
|
||||
const { offsetLeft, offsetWidth } = activeElement as HTMLElement;
|
||||
setHighlightStyle({
|
||||
left: `${offsetLeft}px`,
|
||||
width: `${offsetWidth}px`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [currentOption]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between rounded-md border p-1">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex w-full items-center justify-between rounded-md border bg-white p-1">
|
||||
<div
|
||||
className="absolute bottom-1 top-1 rounded-md bg-slate-100 transition-all duration-300 ease-in-out"
|
||||
style={highlightStyle}
|
||||
/>
|
||||
{questionTypes.map((type) => (
|
||||
<div
|
||||
key={type.value}
|
||||
onClick={() => !disabled && handleOptionChange(type.value)}
|
||||
className={`flex-grow cursor-pointer rounded-md bg-${
|
||||
(currentOption === undefined && type.value === "text") || currentOption === type.value
|
||||
? "slate-100"
|
||||
: "white"
|
||||
} p-2 text-center ${disabled ? "cursor-not-allowed" : ""}`}>
|
||||
data-value={type.value}
|
||||
onClick={() => !type.disabled && handleOptionChange(type.value)}
|
||||
className={`relative z-10 flex-grow rounded-md p-2 text-center transition-colors duration-200 ${
|
||||
type.disabled
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: currentOption === type.value
|
||||
? ""
|
||||
: "cursor-pointer hover:bg-slate-50"
|
||||
}`}>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<span className="text-sm text-slate-900">{type.label}</span>
|
||||
{type.icon ? (
|
||||
<div className="h-4 w-4 text-slate-600 hover:text-slate-800">{type.icon}</div>
|
||||
) : null}
|
||||
{type.icon && <div className="h-4 w-4 text-slate-600 hover:text-slate-800">{type.icon}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -8,7 +8,7 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
|
||||
return (
|
||||
<Input
|
||||
autoFocus={true}
|
||||
className="mt-2 w-96 overflow-hidden text-ellipsis rounded-lg border bg-slate-50 px-3 py-2 text-center text-slate-800 caret-transparent"
|
||||
className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-center text-slate-800 caret-transparent"
|
||||
defaultValue={surveyUrl}
|
||||
/>
|
||||
);
|
||||
|
||||
91
pnpm-lock.yaml
generated
91
pnpm-lock.yaml
generated
@@ -411,7 +411,7 @@ importers:
|
||||
version: 0.0.22(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@sentry/nextjs':
|
||||
specifier: ^8.26.0
|
||||
version: 8.26.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.93.0)
|
||||
version: 8.26.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.93.0)
|
||||
'@tanstack/react-table':
|
||||
specifier: ^8.20.1
|
||||
version: 8.20.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -420,7 +420,7 @@ importers:
|
||||
version: 0.6.2
|
||||
'@vercel/speed-insights':
|
||||
specifier: ^1.0.12
|
||||
version: 1.0.12(next@14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
|
||||
version: 1.0.12(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
|
||||
bcryptjs:
|
||||
specifier: ^2.4.3
|
||||
version: 2.4.3
|
||||
@@ -430,6 +430,9 @@ importers:
|
||||
encoding:
|
||||
specifier: ^0.1.13
|
||||
version: 0.1.13
|
||||
file-loader:
|
||||
specifier: ^6.2.0
|
||||
version: 6.2.0(webpack@5.93.0)
|
||||
framer-motion:
|
||||
specifier: 11.3.28
|
||||
version: 11.3.28(@emotion/is-prop-valid@0.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -456,10 +459,10 @@ importers:
|
||||
version: 4.0.4
|
||||
next:
|
||||
specifier: 14.2.5
|
||||
version: 14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
version: 14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
next-safe-action:
|
||||
specifier: ^7.6.2
|
||||
version: 7.7.0(next@14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@3.23.8)
|
||||
version: 7.7.0(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@3.23.8)
|
||||
optional:
|
||||
specifier: ^0.1.4
|
||||
version: 0.1.4
|
||||
@@ -511,7 +514,7 @@ importers:
|
||||
version: link:../../packages/config-eslint
|
||||
'@neshca/cache-handler':
|
||||
specifier: ^1.5.1
|
||||
version: 1.5.1(next@14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(redis@4.7.0)
|
||||
version: 1.5.1(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(redis@4.7.0)
|
||||
'@types/bcryptjs':
|
||||
specifier: ^2.4.6
|
||||
version: 2.4.6
|
||||
@@ -619,7 +622,7 @@ importers:
|
||||
version: 8.4.41
|
||||
tailwindcss:
|
||||
specifier: ^3.4.10
|
||||
version: 3.4.10(ts-node@10.9.2)
|
||||
version: 3.4.10(ts-node@10.9.2(@types/node@22.3.0)(typescript@5.5.4))
|
||||
|
||||
packages/config-typescript:
|
||||
devDependencies:
|
||||
@@ -1016,7 +1019,7 @@ importers:
|
||||
version: 14.2.3
|
||||
tailwindcss:
|
||||
specifier: ^3.4.10
|
||||
version: 3.4.10(ts-node@10.9.2)
|
||||
version: 3.4.10(ts-node@10.9.2(@types/node@22.3.0)(typescript@5.5.4))
|
||||
terser:
|
||||
specifier: ^5.31.6
|
||||
version: 5.31.6
|
||||
@@ -8208,6 +8211,12 @@ packages:
|
||||
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
|
||||
engines: {node: ^10.12.0 || >=12.0.0}
|
||||
|
||||
file-loader@6.2.0:
|
||||
resolution: {integrity: sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==}
|
||||
engines: {node: '>= 10.13.0'}
|
||||
peerDependencies:
|
||||
webpack: ^4.0.0 || ^5.0.0
|
||||
|
||||
filesize@10.1.2:
|
||||
resolution: {integrity: sha512-Dx770ai81ohflojxhU+oG+Z2QGvKdYxgEr9OSA8UVrqhwNHjfH9A8f5NKfg83fEH8ZFA5N5llJo5T3PIoZ4CRA==}
|
||||
engines: {node: '>= 10.4.0'}
|
||||
@@ -16577,11 +16586,11 @@ snapshots:
|
||||
|
||||
'@microsoft/tsdoc@0.14.2': {}
|
||||
|
||||
'@neshca/cache-handler@1.5.1(next@14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(redis@4.7.0)':
|
||||
'@neshca/cache-handler@1.5.1(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(redis@4.7.0)':
|
||||
dependencies:
|
||||
cluster-key-slot: 1.1.2
|
||||
lru-cache: 10.3.0
|
||||
next: 14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
next: 14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
redis: 4.7.0
|
||||
|
||||
'@next/env@13.5.6': {}
|
||||
@@ -18931,7 +18940,7 @@ snapshots:
|
||||
'@sentry/types': 8.26.0
|
||||
'@sentry/utils': 8.26.0
|
||||
|
||||
'@sentry/nextjs@8.26.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.93.0)':
|
||||
'@sentry/nextjs@8.26.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.93.0)':
|
||||
dependencies:
|
||||
'@opentelemetry/instrumentation-http': 0.52.1(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/semantic-conventions': 1.25.1
|
||||
@@ -18945,7 +18954,7 @@ snapshots:
|
||||
'@sentry/vercel-edge': 8.26.0
|
||||
'@sentry/webpack-plugin': 2.20.1(encoding@0.1.13)(webpack@5.93.0)
|
||||
chalk: 3.0.0
|
||||
next: 14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
next: 14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
resolve: 1.22.8
|
||||
rollup: 3.29.4
|
||||
stacktrace-parser: 0.1.10
|
||||
@@ -19806,7 +19815,7 @@ snapshots:
|
||||
'@tailwindcss/forms@0.5.7(tailwindcss@3.4.10(ts-node@10.9.2))':
|
||||
dependencies:
|
||||
mini-svg-data-uri: 1.4.4
|
||||
tailwindcss: 3.4.10(ts-node@10.9.2)
|
||||
tailwindcss: 3.4.10(ts-node@10.9.2(@types/node@22.3.0)(typescript@5.5.4))
|
||||
|
||||
'@tailwindcss/typography@0.5.13(tailwindcss@3.4.7(ts-node@10.9.2(typescript@5.5.4)))':
|
||||
dependencies:
|
||||
@@ -19822,7 +19831,7 @@ snapshots:
|
||||
lodash.isplainobject: 4.0.6
|
||||
lodash.merge: 4.6.2
|
||||
postcss-selector-parser: 6.0.10
|
||||
tailwindcss: 3.4.10(ts-node@10.9.2)
|
||||
tailwindcss: 3.4.10(ts-node@10.9.2(@types/node@22.3.0)(typescript@5.5.4))
|
||||
|
||||
'@tanstack/react-table@8.20.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
@@ -20468,9 +20477,9 @@ snapshots:
|
||||
satori: 0.10.9
|
||||
yoga-wasm-web: 0.3.3
|
||||
|
||||
'@vercel/speed-insights@1.0.12(next@14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
|
||||
'@vercel/speed-insights@1.0.12(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
|
||||
optionalDependencies:
|
||||
next: 14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
next: 14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react: 18.3.1
|
||||
|
||||
'@vercel/style-guide@6.0.0(@next/eslint-plugin-next@14.2.5)(eslint@8.57.0)(prettier@3.3.3)(typescript@5.5.4)(vitest@2.0.5)':
|
||||
@@ -23160,6 +23169,12 @@ snapshots:
|
||||
dependencies:
|
||||
flat-cache: 3.2.0
|
||||
|
||||
file-loader@6.2.0(webpack@5.93.0):
|
||||
dependencies:
|
||||
loader-utils: 2.0.4
|
||||
schema-utils: 3.3.0
|
||||
webpack: 5.93.0
|
||||
|
||||
filesize@10.1.2: {}
|
||||
|
||||
fill-range@7.1.1:
|
||||
@@ -25747,9 +25762,9 @@ snapshots:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
next-safe-action@7.7.0(next@14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@3.23.8):
|
||||
next-safe-action@7.7.0(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@3.23.8):
|
||||
dependencies:
|
||||
next: 14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
next: 14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
optionalDependencies:
|
||||
@@ -25800,33 +25815,6 @@ snapshots:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
next@14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@next/env': 14.2.5
|
||||
'@swc/helpers': 0.5.5
|
||||
busboy: 1.6.0
|
||||
caniuse-lite: 1.0.30001636
|
||||
graceful-fs: 4.2.11
|
||||
postcss: 8.4.31
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
styled-jsx: 5.1.1(@babel/core@7.25.2)(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 14.2.5
|
||||
'@next/swc-darwin-x64': 14.2.5
|
||||
'@next/swc-linux-arm64-gnu': 14.2.5
|
||||
'@next/swc-linux-arm64-musl': 14.2.5
|
||||
'@next/swc-linux-x64-gnu': 14.2.5
|
||||
'@next/swc-linux-x64-musl': 14.2.5
|
||||
'@next/swc-win32-arm64-msvc': 14.2.5
|
||||
'@next/swc-win32-ia32-msvc': 14.2.5
|
||||
'@next/swc-win32-x64-msvc': 14.2.5
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@playwright/test': 1.45.3
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@next/env': 14.2.5
|
||||
@@ -26505,7 +26493,7 @@ snapshots:
|
||||
postcss: 8.4.40
|
||||
ts-node: 10.9.2(@swc/core@1.3.101)(@types/node@22.3.0)(typescript@5.5.4)
|
||||
|
||||
postcss-load-config@4.0.2(postcss@8.4.41)(ts-node@10.9.2):
|
||||
postcss-load-config@4.0.2(postcss@8.4.41)(ts-node@10.9.2(@types/node@22.3.0)(typescript@5.5.4)):
|
||||
dependencies:
|
||||
lilconfig: 3.1.2
|
||||
yaml: 2.4.5
|
||||
@@ -28271,13 +28259,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.24.5
|
||||
|
||||
styled-jsx@5.1.1(@babel/core@7.25.2)(react@18.3.1):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 18.3.1
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.25.2
|
||||
|
||||
styled-jsx@5.1.1(react@19.0.0-rc-935180c7e0-20240524):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
@@ -28373,7 +28354,7 @@ snapshots:
|
||||
postcss: 8.4.41
|
||||
postcss-import: 15.1.0(postcss@8.4.41)
|
||||
postcss-js: 4.0.1(postcss@8.4.41)
|
||||
postcss-load-config: 4.0.2(postcss@8.4.41)(ts-node@10.9.2)
|
||||
postcss-load-config: 4.0.2(postcss@8.4.41)(ts-node@10.9.2(@types/node@22.3.0)(typescript@5.5.4))
|
||||
postcss-nested: 6.0.1(postcss@8.4.41)
|
||||
postcss-selector-parser: 6.1.0
|
||||
resolve: 1.22.8
|
||||
@@ -28381,7 +28362,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
|
||||
tailwindcss@3.4.10(ts-node@10.9.2):
|
||||
tailwindcss@3.4.10(ts-node@10.9.2(@types/node@22.3.0)(typescript@5.5.4)):
|
||||
dependencies:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
arg: 5.0.2
|
||||
@@ -28400,7 +28381,7 @@ snapshots:
|
||||
postcss: 8.4.41
|
||||
postcss-import: 15.1.0(postcss@8.4.41)
|
||||
postcss-js: 4.0.1(postcss@8.4.41)
|
||||
postcss-load-config: 4.0.2(postcss@8.4.41)(ts-node@10.9.2)
|
||||
postcss-load-config: 4.0.2(postcss@8.4.41)(ts-node@10.9.2(@types/node@22.3.0)(typescript@5.5.4))
|
||||
postcss-nested: 6.0.1(postcss@8.4.41)
|
||||
postcss-selector-parser: 6.1.0
|
||||
resolve: 1.22.8
|
||||
|
||||
Reference in New Issue
Block a user