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:
Piyush Gupta
2024-09-20 14:47:18 +05:30
committed by GitHub
parent e4fceb2e5e
commit 59a29dd3d6
51 changed files with 1097 additions and 319 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

@@ -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(\?.*)?$/);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -0,0 +1,4 @@
declare module "*.mp4" {
const src: string;
export default src;
}

View File

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

View File

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

View File

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

View File

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

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