mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-01 11:22:46 -05:00
feat: Product Model Revamp (#4353)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com> Co-authored-by: Matthias Nannt <mail@matthiasnannt.com> Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
|
||||
import { EmptyContent, ModalButton } from "@/modules/ui/components/empty-content";
|
||||
import { FolderIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface ProjectLimitModalProps {
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
projectLimit: number;
|
||||
buttons: [ModalButton, ModalButton];
|
||||
}
|
||||
|
||||
export const ProjectLimitModal = ({ open, setOpen, projectLimit, buttons }: ProjectLimitModalProps) => {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="w-full max-w-[564px] bg-white">
|
||||
<DialogTitle>{t("common.projects_limit_reached")}</DialogTitle>
|
||||
<EmptyContent
|
||||
icon={<FolderIcon className="h-6 w-6 text-slate-900" />}
|
||||
title={t("common.unlock_more_projects_with_a_higher_plan")}
|
||||
description={t("common.you_have_reached_your_limit_of_project_limit", { projectLimit })}
|
||||
buttons={buttons}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { ModalButton } from "@/modules/ui/components/empty-content";
|
||||
import { BlendIcon, ChevronRightIcon, GlobeIcon, GlobeLockIcon, LinkIcon, PlusIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
|
||||
interface ProjectSwitcherProps {
|
||||
isCollapsed: boolean;
|
||||
isTextVisible: boolean;
|
||||
project: TProject;
|
||||
projects: TProject[];
|
||||
organization: TOrganization;
|
||||
organizationProjectsLimit: number;
|
||||
isFormbricksCloud: boolean;
|
||||
isLicenseActive: boolean;
|
||||
environmentId: string;
|
||||
isOwnerOrManager: boolean;
|
||||
}
|
||||
|
||||
export const ProjectSwitcher = ({
|
||||
isCollapsed,
|
||||
isTextVisible,
|
||||
organization,
|
||||
project,
|
||||
projects,
|
||||
organizationProjectsLimit,
|
||||
isFormbricksCloud,
|
||||
isLicenseActive,
|
||||
environmentId,
|
||||
isOwnerOrManager,
|
||||
}: ProjectSwitcherProps) => {
|
||||
const [openLimitModal, setOpenLimitModal] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const handleEnvironmentChangeByProject = (projectId: string) => {
|
||||
router.push(`/projects/${projectId}/`);
|
||||
};
|
||||
|
||||
const handleAddProject = (organizationId: string) => {
|
||||
if (projects.length >= organizationProjectsLimit) {
|
||||
setOpenLimitModal(true);
|
||||
return;
|
||||
}
|
||||
router.push(`/organizations/${organizationId}/projects/new/mode`);
|
||||
};
|
||||
|
||||
const LimitModalButtons = (): [ModalButton, ModalButton] => {
|
||||
if (isFormbricksCloud && organization.billing.plan !== "enterprise") {
|
||||
return [
|
||||
{
|
||||
text:
|
||||
organization.billing.plan === "free"
|
||||
? t("environments.settings.billing.start_free_trial")
|
||||
: t("environments.settings.billing.upgrade"),
|
||||
onClick: () => {
|
||||
setOpenLimitModal(false);
|
||||
router.push(`/environments/${environmentId}/settings/billing`);
|
||||
},
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
onClick: () => {
|
||||
setOpenLimitModal(false);
|
||||
router.push(`/environments/${environmentId}/settings/billing`);
|
||||
},
|
||||
},
|
||||
];
|
||||
} else {
|
||||
if (isLicenseActive) {
|
||||
return [
|
||||
{
|
||||
text: t("environments.settings.billing.get_in_touch"),
|
||||
href: "https://cal.com/johannes/license",
|
||||
onClick: () => setOpenLimitModal(false),
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
href: "https://formbricks.com/docs/self-hosting/license",
|
||||
onClick: () => setOpenLimitModal(false),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
text:
|
||||
organization.billing.plan === "free"
|
||||
? t("environments.settings.billing.start_free_trial")
|
||||
: t("environments.settings.billing.get_in_touch"),
|
||||
href: "https://formbricks.com/docs/self-hosting/license#30-day-trial-license-request",
|
||||
onClick: () => setOpenLimitModal(false),
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
href: "https://formbricks.com/docs/self-hosting/license",
|
||||
onClick: () => setOpenLimitModal(false),
|
||||
},
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
id="projectDropdownTrigger"
|
||||
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||
<div
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-row items-center space-x-3",
|
||||
isCollapsed ? "pl-2" : "pl-4"
|
||||
)}>
|
||||
<div className="rounded-lg bg-slate-900 p-1.5 text-slate-50">
|
||||
{project.config.channel === "website" ? (
|
||||
<GlobeIcon strokeWidth={1.5} />
|
||||
) : project.config.channel === "app" ? (
|
||||
<GlobeLockIcon strokeWidth={1.5} />
|
||||
) : project.config.channel === "link" ? (
|
||||
<LinkIcon strokeWidth={1.5} />
|
||||
) : (
|
||||
<BlendIcon strokeWidth={1.5} />
|
||||
)}
|
||||
</div>
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
<>
|
||||
<div>
|
||||
<p
|
||||
title={project.name}
|
||||
className={cn(
|
||||
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700 transition-opacity duration-200",
|
||||
isTextVisible ? "opacity-0" : "opacity-100"
|
||||
)}>
|
||||
{project.name}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm text-slate-500 transition-opacity duration-200",
|
||||
isTextVisible ? "opacity-0" : "opacity-100"
|
||||
)}>
|
||||
{project.config.channel === "link"
|
||||
? t("common.link_and_email")
|
||||
: capitalizeFirstLetter(project.config.channel)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"h-5 w-5 text-slate-700 transition-opacity duration-200 hover:text-slate-500",
|
||||
isTextVisible ? "opacity-0" : "opacity-100"
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
id="userDropdownInnerContentWrapper"
|
||||
side="right"
|
||||
sideOffset={10}
|
||||
alignOffset={-1}
|
||||
align="end">
|
||||
<DropdownMenuRadioGroup
|
||||
value={project!.id}
|
||||
onValueChange={(v) => handleEnvironmentChangeByProject(v)}>
|
||||
{projects.map((project) => (
|
||||
<DropdownMenuRadioItem value={project.id} className="cursor-pointer break-all" key={project.id}>
|
||||
<div>
|
||||
{project.config.channel === "website" ? (
|
||||
<GlobeIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
) : project.config.channel === "app" ? (
|
||||
<GlobeLockIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
) : project.config.channel === "link" ? (
|
||||
<LinkIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
) : (
|
||||
<BlendIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
)}
|
||||
</div>
|
||||
<div className="">{project?.name}</div>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
{isOwnerOrManager && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleAddProject(organization.id)}
|
||||
icon={<PlusIcon className="mr-2 h-4 w-4" />}>
|
||||
<span>{t("common.add_project")}</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{openLimitModal && (
|
||||
<ProjectLimitModal
|
||||
open={openLimitModal}
|
||||
setOpen={setOpenLimitModal}
|
||||
buttons={LimitModalButtons()}
|
||||
projectLimit={organizationProjectsLimit}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
|
||||
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export const AppConnectionLoading = () => {
|
||||
const t = useTranslations();
|
||||
const cards = [
|
||||
{
|
||||
title: t("environments.project.app-connection.app_connection"),
|
||||
description: t("environments.project.app-connection.app_connection_description"),
|
||||
skeletonLines: [{ classes: " h-44 max-w-full rounded-lg" }],
|
||||
},
|
||||
{
|
||||
title: t("environments.project.app-connection.how_to_setup"),
|
||||
description: t("environments.project.app-connection.how_to_setup_description"),
|
||||
skeletonLines: [
|
||||
{ classes: "h-12 w-24 rounded-lg" },
|
||||
{ classes: "h-10 w-60 rounded-lg" },
|
||||
{ classes: "h-10 w-60 rounded-lg" },
|
||||
{ classes: "h-12 w-24 rounded-lg" },
|
||||
{ classes: "h-10 w-60 rounded-lg" },
|
||||
{ classes: "h-10 w-60 rounded-lg" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("environments.project.app-connection.environment_id"),
|
||||
description: t("environments.project.app-connection.environment_id_description"),
|
||||
skeletonLines: [{ classes: "h-12 w-4/6 rounded-lg" }],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.configuration")}>
|
||||
<ProjectConfigNavigation activeId="app-connection" loading />
|
||||
</PageHeader>
|
||||
<div className="mt-4 flex max-w-4xl animate-pulse items-center space-y-4 rounded-lg border bg-blue-50 p-6 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base"></div>
|
||||
{cards.map((card, index) => (
|
||||
<LoadingCard key={index} {...card} />
|
||||
))}
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { WidgetStatusIndicator } from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import {
|
||||
getMultiLanguagePermission,
|
||||
getRoleManagementPermission,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { EnvironmentIdField } from "@/modules/projects/settings/(setup)/components/environment-id-field";
|
||||
import { SetupInstructions } from "@/modules/projects/settings/(setup)/components/setup-instructions";
|
||||
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
|
||||
import { EnvironmentNotice } from "@/modules/ui/components/environment-notice";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
|
||||
export const AppConnectionPage = async (props) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
const [environment, organization] = await Promise.all([
|
||||
getEnvironment(params.environmentId),
|
||||
getOrganizationByEnvironmentId(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
|
||||
const canDoRoleManagement = await getRoleManagementPermission(organization);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.configuration")}>
|
||||
<ProjectConfigNavigation
|
||||
environmentId={params.environmentId}
|
||||
activeId="app-connection"
|
||||
isMultiLanguageAllowed={isMultiLanguageAllowed}
|
||||
canDoRoleManagement={canDoRoleManagement}
|
||||
/>
|
||||
</PageHeader>
|
||||
<div className="space-y-4">
|
||||
<EnvironmentNotice environmentId={params.environmentId} subPageUrl="/project/app-connection" />
|
||||
<SettingsCard
|
||||
title={t("environments.project.app-connection.app_connection")}
|
||||
description={t("environments.project.app-connection.app_connection_description")}>
|
||||
{environment && <WidgetStatusIndicator environment={environment} />}
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title={t("environments.project.app-connection.how_to_setup")}
|
||||
description={t("environments.project.app-connection.how_to_setup_description")}
|
||||
noPadding>
|
||||
<SetupInstructions environmentId={params.environmentId} webAppUrl={WEBAPP_URL} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title={t("environments.project.app-connection.environment_id")}
|
||||
description={t("environments.project.app-connection.environment_id_description")}>
|
||||
<EnvironmentIdField environmentId={params.environmentId} />
|
||||
</SettingsCard>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { CodeBlock } from "@/modules/ui/components/code-block";
|
||||
|
||||
export const EnvironmentIdField = ({ environmentId }: { environmentId: string }) => {
|
||||
return (
|
||||
<div className="prose prose-slate -mt-3">
|
||||
<CodeBlock language="js">{environmentId}</CodeBlock>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { CodeBlock } from "@/modules/ui/components/code-block";
|
||||
import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons";
|
||||
import { TabBar } from "@/modules/ui/components/tab-bar";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import "prismjs/themes/prism.css";
|
||||
import { useState } from "react";
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: "npm",
|
||||
label: "NPM",
|
||||
icon: <NpmIcon />,
|
||||
},
|
||||
{ id: "html", label: "HTML", icon: <Html5Icon /> },
|
||||
];
|
||||
|
||||
interface SetupInstructionsProps {
|
||||
environmentId: string;
|
||||
webAppUrl: string;
|
||||
}
|
||||
|
||||
export const SetupInstructions = ({ environmentId, webAppUrl }: SetupInstructionsProps) => {
|
||||
const t = useTranslations();
|
||||
const [activeTab, setActiveTab] = useState(tabs[0].id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TabBar tabs={tabs} activeId={activeTab} setActiveId={setActiveTab} />
|
||||
<div className="px-6 py-5">
|
||||
{activeTab === "npm" ? (
|
||||
<div className="prose prose-slate prose-p:my-2 prose-p:text-sm prose-p:text-slate-600 prose-h4:text-slate-800 prose-h4:pt-2">
|
||||
<h4>{t("environments.project.app-connection.step_1")}</h4>
|
||||
<CodeBlock language="sh">pnpm install @formbricks/js</CodeBlock>
|
||||
<p>or</p>
|
||||
<CodeBlock language="sh">npm install @formbricks/js</CodeBlock>
|
||||
<p>or</p>
|
||||
<CodeBlock language="sh">yarn add @formbricks/js</CodeBlock>
|
||||
<h4>{t("environments.project.app-connection.step_2")}</h4>
|
||||
<p>{t("environments.project.app-connection.step_2_description")}</p>
|
||||
<CodeBlock language="js">{`import formbricks from "@formbricks/js";
|
||||
if (typeof window !== "undefined") {
|
||||
formbricks.init({
|
||||
environmentId: "${environmentId}",
|
||||
apiHost: "${webAppUrl}",
|
||||
});
|
||||
}`}</CodeBlock>
|
||||
<ul className="list-disc text-sm">
|
||||
<li>
|
||||
<span className="font-semibold">environmentId :</span>{" "}
|
||||
{t("environments.project.app-connection.environment_id_description_with_environment_id", {
|
||||
environmentId: environmentId,
|
||||
})}
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-semibold">apiHost:</span>{" "}
|
||||
{t("environments.project.app-connection.api_host_description")}
|
||||
</li>
|
||||
</ul>
|
||||
<span className="text-sm text-slate-600">
|
||||
{t("environments.project.app-connection.if_you_are_planning_to")}
|
||||
<Link
|
||||
href="https://formbricks.com//docs/app-surveys/user-identification"
|
||||
target="blank"
|
||||
className="underline">
|
||||
{t("environments.project.app-connection.identifying_your_users")}
|
||||
</Link>{" "}
|
||||
{t("environments.project.app-connection.you_also_need_to_pass_a")}{" "}
|
||||
<span className="font-semibold">userId</span> {t("environments.project.app-connection.to_the")}{" "}
|
||||
<span className="font-semibold">init</span> {t("environments.project.app-connection.function")}.
|
||||
</span>
|
||||
<h4>{t("environments.project.app-connection.step_3")}</h4>
|
||||
<p>
|
||||
{t("environments.project.app-connection.switch_on_the_debug_mode_by_appending")}{" "}
|
||||
<i>?formbricksDebug=true</i>{" "}
|
||||
{t("environments.project.app-connection.to_the_url_where_you_load_the")}{" "}
|
||||
{t("environments.project.app-connection.formbricks_sdk")}.{" "}
|
||||
{t("environments.project.app-connection.open_the_browser_console_to_see_the_logs")}{" "}
|
||||
<Link
|
||||
className="decoration-brand-dark"
|
||||
href="https://formbricks.com/docs/developer-docs/js-sdk#debug-mode"
|
||||
target="_blank">
|
||||
{t("common.read_docs")}
|
||||
</Link>{" "}
|
||||
</p>
|
||||
<h4>{t("environments.project.app-connection.you_are_done")}</h4>
|
||||
<p>{t("environments.project.app-connection.your_app_now_communicates_with_formbricks")}</p>
|
||||
<ul className="list-disc text-sm text-slate-700">
|
||||
<li>
|
||||
<span>{t("environments.project.app-connection.need_a_more_detailed_setup_guide_for")}</span>{" "}
|
||||
<Link
|
||||
className="decoration-brand-dark"
|
||||
href="https://formbricks.com/docs/website-surveys/quickstart"
|
||||
target="_blank">
|
||||
{t("environments.project.app-connection.check_out_the_docs")}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<span>{t("environments.project.app-connection.not_working")}</span>{" "}
|
||||
<Link
|
||||
className="decoration-brand-dark"
|
||||
target="_blank"
|
||||
href="https://github.com/formbricks/formbricks/issues">
|
||||
{t("environments.project.app-connection.open_an_issue_on_github")}
|
||||
</Link>{" "}
|
||||
</li>
|
||||
<li>
|
||||
<span>
|
||||
{t("environments.project.app-connection.want_to_learn_how_to_add_user_attributes")}
|
||||
</span>{" "}
|
||||
<Link
|
||||
className="decoration-brand-dark"
|
||||
href="https://formbricks.com/docs/attributes/why"
|
||||
target="_blank">
|
||||
{t("environments.project.app-connection.dive_into_the_docs")}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
) : activeTab === "html" ? (
|
||||
<div className="prose prose-slate prose-p:my-2 prose-p:text-sm prose-p:text-slate-600 prose-h4:text-slate-800 prose-h4:pt-2">
|
||||
<h4>{t("environments.project.app-connection.step_1")}</h4>
|
||||
<p>
|
||||
{t("environments.project.app-connection.insert_this_code_into_the")} <code>{`<head>`}</code>{" "}
|
||||
{t("environments.project.app-connection.tag_of_your_app")}
|
||||
</p>
|
||||
<CodeBlock language="js">{`<!-- START Formbricks Surveys -->
|
||||
<script type="text/javascript">
|
||||
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="${webAppUrl}/js/formbricks.umd.cjs";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: "${environmentId}", apiHost: "${window.location.protocol}//${window.location.host}"})},500)}();
|
||||
</script>
|
||||
<!-- END Formbricks Surveys -->`}</CodeBlock>
|
||||
<h4>Step 2: Debug mode</h4>
|
||||
<p>
|
||||
{t("environments.project.app-connection.switch_on_the_debug_mode_by_appending")}{" "}
|
||||
<i>{`?formbricksDebug=true`}</i>{" "}
|
||||
{t("environments.project.app-connection.to_the_url_where_you_load_the")}{" "}
|
||||
{t("environments.project.app-connection.formbricks_sdk")}.{" "}
|
||||
{t("environments.project.app-connection.open_the_browser_console_to_see_the_logs")}{" "}
|
||||
<Link
|
||||
className="decoration-brand-dark"
|
||||
href="https://formbricks.com/docs/developer-docs/js-sdk#debug-mode"
|
||||
target="_blank">
|
||||
{t("common.read_docs")}
|
||||
</Link>{" "}
|
||||
</p>
|
||||
<h4>{t("environments.project.app-connection.you_are_done")}</h4>
|
||||
<p>{t("environments.project.app-connection.your_app_now_communicates_with_formbricks")}</p>
|
||||
<ul className="list-disc text-sm text-slate-700">
|
||||
<li>
|
||||
<span className="font-semibold">
|
||||
{t("environments.project.app-connection.does_your_widget_work")}
|
||||
</span>
|
||||
<span>{t("environments.project.app-connection.scroll_to_the_top")}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-semibold">
|
||||
{t("environments.project.app-connection.have_a_problem")}
|
||||
</span>{" "}
|
||||
<Link
|
||||
className="decoration-brand-dark"
|
||||
target="_blank"
|
||||
href="https://github.com/formbricks/formbricks/issues">
|
||||
{t("environments.project.app-connection.open_an_issue_on_github")}
|
||||
</Link>{" "}
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-semibold">
|
||||
{t("environments.project.app-connection.want_to_learn_how_to_add_user_attributes")}
|
||||
</span>{" "}
|
||||
<Link
|
||||
className="decoration-brand-dark"
|
||||
href="https://formbricks.com/docs/attributes/why"
|
||||
target="_blank">
|
||||
{t("environments.project.app-connection.dive_into_the_docs")}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
"use server";
|
||||
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
|
||||
import {
|
||||
getRemoveInAppBrandingPermission,
|
||||
getRemoveLinkBrandingPermission,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { updateProject } from "@/modules/projects/settings/lib/project";
|
||||
import { z } from "zod";
|
||||
import { getOrganization } from "@formbricks/lib/organization/service";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
||||
|
||||
const ZUpdateProjectAction = z.object({
|
||||
projectId: ZId,
|
||||
data: ZProjectUpdateInput,
|
||||
});
|
||||
|
||||
export const updateProjectAction = authenticatedActionClient
|
||||
.schema(ZUpdateProjectAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
schema: ZProjectUpdateInput,
|
||||
data: parsedInput.data,
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: parsedInput.projectId,
|
||||
minPermission: "manage",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (
|
||||
parsedInput.data.inAppSurveyBranding !== undefined ||
|
||||
parsedInput.data.linkSurveyBranding !== undefined
|
||||
) {
|
||||
const organization = await getOrganization(organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
if (parsedInput.data.inAppSurveyBranding !== undefined) {
|
||||
const canRemoveInAppBranding = getRemoveInAppBrandingPermission(organization);
|
||||
if (!canRemoveInAppBranding) {
|
||||
throw new OperationNotAllowedError("You are not allowed to remove in-app branding");
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedInput.data.linkSurveyBranding !== undefined) {
|
||||
const canRemoveLinkSurveyBranding = getRemoveLinkBrandingPermission(organization);
|
||||
if (!canRemoveLinkSurveyBranding) {
|
||||
throw new OperationNotAllowedError("You are not allowed to remove link survey branding");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await updateProject(parsedInput.projectId, parsedInput.data);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
"use server";
|
||||
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import {
|
||||
getOrganizationIdFromApiKeyId,
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
getProjectIdFromApiKeyId,
|
||||
getProjectIdFromEnvironmentId,
|
||||
} from "@/lib/utils/helper";
|
||||
import { createApiKey, deleteApiKey } from "@/modules/projects/settings/lib/api-key";
|
||||
import { z } from "zod";
|
||||
import { ZApiKeyCreateInput } from "@formbricks/types/api-keys";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
const ZDeleteApiKeyAction = z.object({
|
||||
id: ZId,
|
||||
});
|
||||
|
||||
export const deleteApiKeyAction = authenticatedActionClient
|
||||
.schema(ZDeleteApiKeyAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromApiKeyId(parsedInput.id),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "manage",
|
||||
projectId: await getProjectIdFromApiKeyId(parsedInput.id),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await deleteApiKey(parsedInput.id);
|
||||
});
|
||||
|
||||
const ZCreateApiKeyAction = z.object({
|
||||
environmentId: ZId,
|
||||
apiKeyData: ZApiKeyCreateInput,
|
||||
});
|
||||
|
||||
export const createApiKeyAction = authenticatedActionClient
|
||||
.schema(ZCreateApiKeyAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "manage",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await createApiKey(parsedInput.environmentId, parsedInput.apiKeyData);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { AlertTriangleIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
interface MemberModalProps {
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
onSubmit: (data: { label: string; environment: string }) => void;
|
||||
}
|
||||
|
||||
export const AddApiKeyModal = ({ open, setOpen, onSubmit }: MemberModalProps) => {
|
||||
const t = useTranslations();
|
||||
const { register, getValues, handleSubmit, reset } = useForm<{ label: string; environment: string }>();
|
||||
|
||||
const submitAPIKey = async () => {
|
||||
const data = getValues();
|
||||
onSubmit(data);
|
||||
setOpen(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false}>
|
||||
<div className="flex h-full flex-col rounded-lg">
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-xl font-medium text-slate-700">
|
||||
{t("environments.project.api-keys.add_api_key")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(submitAPIKey)}>
|
||||
<div className="flex justify-between rounded-lg p-6">
|
||||
<div className="w-full space-y-4">
|
||||
<div>
|
||||
<Label>{t("environments.project.api-keys.api_key_label")}</Label>
|
||||
<Input
|
||||
placeholder="e.g. GitHub, PostHog, Slack"
|
||||
{...register("label", { required: true, validate: (value) => value.trim() !== "" })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-700">
|
||||
<AlertTriangleIcon className="mx-3 h-12 w-12 text-amber-500" />
|
||||
<p>{t("environments.project.api-keys.api_key_security_warning")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-200 p-6">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{t("environments.project.api-keys.add_api_key")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getApiKeys } from "@formbricks/lib/apiKey/service";
|
||||
import { getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { EditAPIKeys } from "./edit-api-keys";
|
||||
|
||||
interface ApiKeyListProps {
|
||||
environmentId: string;
|
||||
environmentType: string;
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const ApiKeyList = async ({ environmentId, environmentType, locale, isReadOnly }: ApiKeyListProps) => {
|
||||
const t = await getTranslations();
|
||||
const findEnvironmentByType = (environments, targetType) => {
|
||||
for (const environment of environments) {
|
||||
if (environment.type === targetType) {
|
||||
return environment.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const project = await getProjectByEnvironmentId(environmentId);
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
const environments = await getEnvironments(project.id);
|
||||
const environmentTypeId = findEnvironmentByType(environments, environmentType);
|
||||
const apiKeys = await getApiKeys(environmentTypeId);
|
||||
|
||||
return (
|
||||
<EditAPIKeys
|
||||
environmentTypeId={environmentTypeId}
|
||||
environmentType={environmentType}
|
||||
apiKeys={apiKeys}
|
||||
environmentId={environmentId}
|
||||
locale={locale}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { FilesIcon, TrashIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { timeSince } from "@formbricks/lib/time";
|
||||
import { TApiKey } from "@formbricks/types/api-keys";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createApiKeyAction, deleteApiKeyAction } from "../actions";
|
||||
import { AddApiKeyModal } from "./add-api-key-modal";
|
||||
|
||||
interface EditAPIKeysProps {
|
||||
environmentTypeId: string;
|
||||
environmentType: string;
|
||||
apiKeys: TApiKey[];
|
||||
environmentId: string;
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const EditAPIKeys = ({
|
||||
environmentTypeId,
|
||||
environmentType,
|
||||
apiKeys,
|
||||
environmentId,
|
||||
locale,
|
||||
isReadOnly,
|
||||
}: EditAPIKeysProps) => {
|
||||
const t = useTranslations();
|
||||
const [isAddAPIKeyModalOpen, setOpenAddAPIKeyModal] = useState(false);
|
||||
const [isDeleteKeyModalOpen, setOpenDeleteKeyModal] = useState(false);
|
||||
const [apiKeysLocal, setApiKeysLocal] = useState<TApiKey[]>(apiKeys);
|
||||
const [activeKey, setActiveKey] = useState({} as any);
|
||||
|
||||
const handleOpenDeleteKeyModal = (e, apiKey) => {
|
||||
e.preventDefault();
|
||||
setActiveKey(apiKey);
|
||||
setOpenDeleteKeyModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteKey = async () => {
|
||||
try {
|
||||
await deleteApiKeyAction({ id: activeKey.id });
|
||||
const updatedApiKeys = apiKeysLocal?.filter((apiKey) => apiKey.id !== activeKey.id) || [];
|
||||
setApiKeysLocal(updatedApiKeys);
|
||||
toast.success(t("environments.project.api-keys.api_key_deleted"));
|
||||
} catch (e) {
|
||||
toast.error(t("environments.project.api-keys.unable_to_delete_api_key"));
|
||||
} finally {
|
||||
setOpenDeleteKeyModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddAPIKey = async (data) => {
|
||||
const createApiKeyResponse = await createApiKeyAction({
|
||||
environmentId: environmentTypeId,
|
||||
apiKeyData: { label: data.label },
|
||||
});
|
||||
console.log("createApiKeyResponse", createApiKeyResponse);
|
||||
if (createApiKeyResponse?.data) {
|
||||
const updatedApiKeys = [...apiKeysLocal!, createApiKeyResponse.data];
|
||||
setApiKeysLocal(updatedApiKeys);
|
||||
toast.success(t("environments.project.api-keys.api_key_created"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createApiKeyResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
|
||||
setOpenAddAPIKeyModal(false);
|
||||
};
|
||||
|
||||
const ApiKeyDisplay = ({ apiKey }) => {
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(apiKey);
|
||||
toast.success(t("environments.project.api-keys.api_key_copied_to_clipboard"));
|
||||
};
|
||||
|
||||
if (!apiKey) {
|
||||
return <span className="italic">{t("environments.project.api-keys.secret")}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<span>{apiKey}</span>
|
||||
<div className="copyApiKeyIcon">
|
||||
<FilesIcon className="mx-2 h-4 w-4 cursor-pointer" onClick={copyToClipboard} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-10 content-center rounded-t-lg bg-slate-100 px-6 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-4 sm:col-span-2">{t("common.label")}</div>
|
||||
<div className="col-span-4 hidden sm:col-span-5 sm:block">
|
||||
{t("environments.project.api-keys.api_key")}
|
||||
</div>
|
||||
<div className="col-span-4 sm:col-span-2">{t("common.created_at")}</div>
|
||||
<div></div>
|
||||
</div>
|
||||
<div className="grid-cols-9">
|
||||
{apiKeysLocal && apiKeysLocal.length === 0 ? (
|
||||
<div className="flex h-12 items-center justify-center whitespace-nowrap px-6 text-sm font-medium text-slate-400">
|
||||
{t("environments.project.api-keys.no_api_keys_yet")}
|
||||
</div>
|
||||
) : (
|
||||
apiKeysLocal &&
|
||||
apiKeysLocal.map((apiKey) => (
|
||||
<div
|
||||
className="grid h-12 w-full grid-cols-10 content-center items-center rounded-lg px-6 text-left text-sm text-slate-900"
|
||||
key={apiKey.hashedKey}>
|
||||
<div className="col-span-4 font-semibold sm:col-span-2">{apiKey.label}</div>
|
||||
<div className="col-span-4 hidden sm:col-span-5 sm:block">
|
||||
<ApiKeyDisplay apiKey={apiKey.apiKey} />
|
||||
</div>
|
||||
<div className="col-span-4 sm:col-span-2">
|
||||
{timeSince(apiKey.createdAt.toString(), locale)}
|
||||
</div>
|
||||
{!isReadOnly && (
|
||||
<div className="col-span-1 text-center">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="minimal"
|
||||
onClick={(e) => handleOpenDeleteKeyModal(e, apiKey)}
|
||||
StartIcon={TrashIcon}
|
||||
startIconClassName={cn("h-5 w-5 text-slate-700", isReadOnly && "opacity-50")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isReadOnly && (
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={environmentId !== environmentTypeId}
|
||||
onClick={() => {
|
||||
setOpenAddAPIKeyModal(true);
|
||||
}}>
|
||||
{t("environments.project.api-keys.add_env_api_key", { environmentType })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<AddApiKeyModal
|
||||
open={isAddAPIKeyModalOpen}
|
||||
setOpen={setOpenAddAPIKeyModal}
|
||||
onSubmit={handleAddAPIKey}
|
||||
/>
|
||||
<DeleteDialog
|
||||
open={isDeleteKeyModalOpen}
|
||||
setOpen={setOpenDeleteKeyModal}
|
||||
deleteWhat={t("environments.project.api-keys.api_key")}
|
||||
onDelete={handleDeleteKey}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const LoadingCard = () => {
|
||||
const t = useTranslations();
|
||||
return (
|
||||
<div className="w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 shadow-sm">
|
||||
<div className="grid content-center border-b border-slate-200 px-4 pb-4 text-left text-slate-900">
|
||||
<h3 className="h-6 w-full max-w-56 animate-pulse rounded-lg bg-slate-100 text-lg font-medium leading-6"></h3>
|
||||
<p className="mt-3 h-4 w-full max-w-80 animate-pulse rounded-lg bg-slate-100 text-sm text-slate-500"></p>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="rounded-lg px-4 pt-4">
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-10 content-center rounded-t-lg bg-slate-100 px-6 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-4 sm:col-span-2">{t("common.label")}</div>
|
||||
<div className="col-span-4 hidden sm:col-span-5 sm:block">
|
||||
{t("environments.project.api-keys.api_key")}
|
||||
</div>
|
||||
<div className="col-span-4 sm:col-span-2">{t("common.created_at")}</div>
|
||||
</div>
|
||||
<div className="px-6">
|
||||
<div className="my-4 h-5 w-full animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-start">
|
||||
<div className="mt-4 flex h-8 w-44 animate-pulse flex-col items-center justify-center rounded-md bg-black text-sm text-white">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const APIKeysLoading = () => {
|
||||
const t = useTranslations();
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.configuration")}>
|
||||
<ProjectConfigNavigation activeId="api-keys" loading />
|
||||
</PageHeader>
|
||||
<div className="mt-4 flex max-w-4xl animate-pulse items-center space-y-4 rounded-lg border bg-blue-50 p-6 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base"></div>
|
||||
<LoadingCard />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import {
|
||||
getMultiLanguagePermission,
|
||||
getRoleManagementPermission,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
|
||||
import { EnvironmentNotice } from "@/modules/ui/components/environment-notice";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
|
||||
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
|
||||
import { ApiKeyList } from "./components/api-key-list";
|
||||
|
||||
export const APIKeysPage = async (props) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
const [session, environment, organization, project] = await Promise.all([
|
||||
getServerSession(authOptions),
|
||||
getEnvironment(params.environmentId),
|
||||
getOrganizationByEnvironmentId(params.environmentId),
|
||||
getProjectByEnvironmentId(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const { isMember } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
|
||||
const { hasManageAccess } = getTeamPermissionFlags(projectPermission);
|
||||
|
||||
const isReadOnly = isMember && !hasManageAccess;
|
||||
|
||||
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
|
||||
const canDoRoleManagement = await getRoleManagementPermission(organization);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.configuration")}>
|
||||
<ProjectConfigNavigation
|
||||
environmentId={params.environmentId}
|
||||
activeId="api-keys"
|
||||
isMultiLanguageAllowed={isMultiLanguageAllowed}
|
||||
canDoRoleManagement={canDoRoleManagement}
|
||||
/>
|
||||
</PageHeader>
|
||||
<EnvironmentNotice environmentId={environment.id} subPageUrl="/project/api-keys" />
|
||||
{environment.type === "development" ? (
|
||||
<SettingsCard
|
||||
title={t("environments.project.api-keys.dev_api_keys")}
|
||||
description={t("environments.project.api-keys.dev_api_keys_description")}>
|
||||
<ApiKeyList
|
||||
environmentId={params.environmentId}
|
||||
environmentType="development"
|
||||
locale={locale}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
</SettingsCard>
|
||||
) : (
|
||||
<SettingsCard
|
||||
title={t("environments.project.api-keys.prod_api_keys")}
|
||||
description={t("environments.project.api-keys.prod_api_keys_description")}>
|
||||
<ApiKeyList
|
||||
environmentId={params.environmentId}
|
||||
environmentType="production"
|
||||
locale={locale}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
</SettingsCard>
|
||||
)}
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
import { BrushIcon, KeyIcon, LanguagesIcon, ListChecksIcon, TagIcon, UsersIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
interface ProjectConfigNavigationProps {
|
||||
activeId: string;
|
||||
environmentId?: string;
|
||||
isMultiLanguageAllowed?: boolean;
|
||||
loading?: boolean;
|
||||
canDoRoleManagement?: boolean;
|
||||
}
|
||||
|
||||
export const ProjectConfigNavigation = ({
|
||||
activeId,
|
||||
environmentId,
|
||||
isMultiLanguageAllowed,
|
||||
loading,
|
||||
canDoRoleManagement,
|
||||
}: ProjectConfigNavigationProps) => {
|
||||
const t = useTranslations();
|
||||
const pathname = usePathname();
|
||||
|
||||
let navigation = [
|
||||
{
|
||||
id: "general",
|
||||
label: t("common.general"),
|
||||
icon: <UsersIcon className="h-5 w-5" />,
|
||||
href: `/environments/${environmentId}/project/general`,
|
||||
current: pathname?.includes("/general"),
|
||||
},
|
||||
{
|
||||
id: "look",
|
||||
label: t("common.look_and_feel"),
|
||||
icon: <BrushIcon className="h-5 w-5" />,
|
||||
href: `/environments/${environmentId}/project/look`,
|
||||
current: pathname?.includes("/look"),
|
||||
},
|
||||
{
|
||||
id: "languages",
|
||||
label: t("common.survey_languages"),
|
||||
icon: <LanguagesIcon className="h-5 w-5" />,
|
||||
href: `/environments/${environmentId}/project/languages`,
|
||||
hidden: !isMultiLanguageAllowed,
|
||||
current: pathname?.includes("/languages"),
|
||||
},
|
||||
{
|
||||
id: "tags",
|
||||
label: t("common.tags"),
|
||||
icon: <TagIcon className="h-5 w-5" />,
|
||||
href: `/environments/${environmentId}/project/tags`,
|
||||
current: pathname?.includes("/tags"),
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
label: t("common.api_keys"),
|
||||
icon: <KeyIcon className="h-5 w-5" />,
|
||||
href: `/environments/${environmentId}/project/api-keys`,
|
||||
current: pathname?.includes("/api-keys"),
|
||||
},
|
||||
{
|
||||
id: "app-connection",
|
||||
label: t("common.website_and_app_connection"),
|
||||
icon: <ListChecksIcon className="h-5 w-5" />,
|
||||
href: `/environments/${environmentId}/project/app-connection`,
|
||||
current: pathname?.includes("/app-connection"),
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
label: t("common.team_access"),
|
||||
href: `/environments/${environmentId}/project/teams`,
|
||||
hidden: !canDoRoleManagement,
|
||||
current: pathname?.includes("/teams"),
|
||||
},
|
||||
];
|
||||
|
||||
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
"use server";
|
||||
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
|
||||
import { deleteProject } from "@/modules/projects/settings/lib/project";
|
||||
import { z } from "zod";
|
||||
import { getUserProjects } from "@formbricks/lib/project/service";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
const ZProjectDeleteAction = z.object({
|
||||
projectId: ZId,
|
||||
});
|
||||
|
||||
export const deleteProjectAction = authenticatedActionClient
|
||||
.schema(ZProjectDeleteAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const availableProjects = (await getUserProjects(ctx.user.id, organizationId)) ?? null;
|
||||
|
||||
if (!!availableProjects && availableProjects?.length <= 1) {
|
||||
throw new Error("You can't delete the last project in the environment.");
|
||||
}
|
||||
|
||||
// delete project
|
||||
return await deleteProject(parsedInput.projectId);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { deleteProjectAction } from "@/modules/projects/settings/general/actions";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@formbricks/lib/localStorage";
|
||||
import { truncate } from "@formbricks/lib/utils/strings";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
|
||||
interface DeleteProjectRenderProps {
|
||||
isDeleteDisabled: boolean;
|
||||
isOwnerOrManager: boolean;
|
||||
project: TProject;
|
||||
}
|
||||
|
||||
export const DeleteProjectRender = ({
|
||||
isDeleteDisabled,
|
||||
isOwnerOrManager,
|
||||
project,
|
||||
}: DeleteProjectRenderProps) => {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const handleDeleteProject = async () => {
|
||||
setIsDeleting(true);
|
||||
const deleteProjectResponse = await deleteProjectAction({ projectId: project.id });
|
||||
if (deleteProjectResponse?.data) {
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
toast.success(t("environments.project.general.project_deleted_successfully"));
|
||||
router.push("/");
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(deleteProjectResponse);
|
||||
toast.error(errorMessage);
|
||||
setIsDeleteDialogOpen(false);
|
||||
}
|
||||
setIsDeleting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!isDeleteDisabled && (
|
||||
<div>
|
||||
<p className="text-sm text-slate-900">
|
||||
{t(
|
||||
"environments.project.general.delete_project_name_includes_surveys_responses_people_and_more",
|
||||
{
|
||||
projectName: truncate(project.name, 30),
|
||||
}
|
||||
)}{" "}
|
||||
<strong>{t("environments.project.general.this_action_cannot_be_undone")}</strong>
|
||||
</p>
|
||||
<Button
|
||||
disabled={isDeleteDisabled}
|
||||
variant="warn"
|
||||
className={`mt-4 ${isDeleteDisabled ? "ring-grey-500 ring-1 ring-offset-1" : ""}`}
|
||||
onClick={() => setIsDeleteDialogOpen(true)}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDeleteDisabled && (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
{!isOwnerOrManager
|
||||
? t("environments.project.general.only_owners_or_managers_can_delete_projects")
|
||||
: t("environments.project.general.cannot_delete_only_project")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DeleteDialog
|
||||
deleteWhat="Project"
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setIsDeleteDialogOpen}
|
||||
onDelete={handleDeleteProject}
|
||||
text={t("environments.project.general.delete_project_confirmation", {
|
||||
projectName: truncate(project.name, 30),
|
||||
})}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { DeleteProjectRender } from "@/modules/projects/settings/general/components/delete-project-render";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getUserProjects } from "@formbricks/lib/project/service";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
|
||||
interface DeleteProjectProps {
|
||||
environmentId: string;
|
||||
project: TProject;
|
||||
isOwnerOrManager: boolean;
|
||||
}
|
||||
|
||||
export const DeleteProject = async ({ environmentId, project, isOwnerOrManager }: DeleteProjectProps) => {
|
||||
const t = await getTranslations();
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
const availableProjects = organization ? await getUserProjects(session.user.id, organization.id) : null;
|
||||
|
||||
const availableProjectsLength = availableProjects ? availableProjects.length : 0;
|
||||
const isDeleteDisabled = availableProjectsLength <= 1 || !isOwnerOrManager;
|
||||
|
||||
return (
|
||||
<DeleteProjectRender
|
||||
isDeleteDisabled={isDeleteDisabled}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
project={project}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { updateProjectAction } from "@/modules/projects/settings/actions";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
FormControl,
|
||||
FormError,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormProvider,
|
||||
} from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { TProject, ZProject } from "@formbricks/types/project";
|
||||
|
||||
interface EditProjectNameProps {
|
||||
project: TProject;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
const ZProjectNameInput = ZProject.pick({ name: true });
|
||||
|
||||
type TEditProjectName = z.infer<typeof ZProjectNameInput>;
|
||||
|
||||
export const EditProjectNameForm: React.FC<EditProjectNameProps> = ({ project, isReadOnly }) => {
|
||||
const t = useTranslations();
|
||||
const form = useForm<TEditProjectName>({
|
||||
defaultValues: {
|
||||
name: project.name,
|
||||
},
|
||||
resolver: zodResolver(ZProjectNameInput),
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const { errors, isDirty } = form.formState;
|
||||
|
||||
const nameError = errors.name?.message;
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const updateProject: SubmitHandler<TEditProjectName> = async (data) => {
|
||||
const name = data.name.trim();
|
||||
try {
|
||||
if (nameError) {
|
||||
toast.error(nameError);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedProjectResponse = await updateProjectAction({
|
||||
projectId: project.id,
|
||||
data: {
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
if (updatedProjectResponse?.data) {
|
||||
toast.success(t("environments.project.general.project_name_updated_successfully"));
|
||||
form.resetField("name", { defaultValue: updatedProjectResponse.data.name });
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error(t("environments.project.general.error_saving_project_information"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormProvider {...form}>
|
||||
<form className="w-full max-w-sm items-center space-y-2" onSubmit={form.handleSubmit(updateProject)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="name">
|
||||
{t("environments.project.general.whats_your_project_called")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
{...field}
|
||||
placeholder={t("common.project_name")}
|
||||
autoComplete="off"
|
||||
required
|
||||
isInvalid={!!nameError}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty || isReadOnly}>
|
||||
{t("common.update")}
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
{isReadOnly && (
|
||||
<Alert variant="warning" className="mt-4">
|
||||
<AlertDescription>
|
||||
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
FormControl,
|
||||
FormError,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormProvider,
|
||||
} from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { TProject, ZProject } from "@formbricks/types/project";
|
||||
import { updateProjectAction } from "../../actions";
|
||||
|
||||
interface EditWaitingTimeProps {
|
||||
project: TProject;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
const ZProjectRecontactDaysInput = ZProject.pick({ recontactDays: true });
|
||||
|
||||
type TEditWaitingTimeFormValues = z.infer<typeof ZProjectRecontactDaysInput>;
|
||||
|
||||
export const EditWaitingTimeForm: React.FC<EditWaitingTimeProps> = ({ project, isReadOnly }) => {
|
||||
const t = useTranslations();
|
||||
const form = useForm<TEditWaitingTimeFormValues>({
|
||||
defaultValues: {
|
||||
recontactDays: project.recontactDays,
|
||||
},
|
||||
resolver: zodResolver(ZProjectRecontactDaysInput),
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const { isDirty, isSubmitting } = form.formState;
|
||||
|
||||
const updateWaitingTime: SubmitHandler<TEditWaitingTimeFormValues> = async (data) => {
|
||||
try {
|
||||
const updatedProjectResponse = await updateProjectAction({ projectId: project.id, data });
|
||||
if (updatedProjectResponse?.data) {
|
||||
toast.success(t("environments.project.general.waiting_period_updated_successfully"));
|
||||
form.resetField("recontactDays", { defaultValue: updatedProjectResponse.data.recontactDays });
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
className="flex w-full max-w-sm flex-col space-y-4"
|
||||
onSubmit={form.handleSubmit(updateWaitingTime)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recontactDays"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="recontactDays">
|
||||
{t("environments.project.general.wait_x_days_before_showing_next_survey")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
id="recontactDays"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") {
|
||||
field.onChange("");
|
||||
}
|
||||
|
||||
field.onChange(parseInt(value, 10));
|
||||
}}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty || isReadOnly}>
|
||||
{t("common.update")}
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
{isReadOnly && (
|
||||
<Alert variant="warning" className="mt-4">
|
||||
<AlertDescription>
|
||||
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
|
||||
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export const GeneralSettingsLoading = () => {
|
||||
const t = useTranslations();
|
||||
const cards = [
|
||||
{
|
||||
title: t("common.project_name"),
|
||||
description: t("environments.project.general.project_name_settings_description"),
|
||||
skeletonLines: [{ classes: "h-4 w-28" }, { classes: "h-6 w-64" }, { classes: "h-8 w-24" }],
|
||||
},
|
||||
{
|
||||
title: t("environments.project.general.recontact_waiting_time"),
|
||||
description: t("environments.project.general.recontact_waiting_time_settings_description"),
|
||||
skeletonLines: [{ classes: "h-4 w-28" }, { classes: "h-6 w-64" }, { classes: "h-8 w-24" }],
|
||||
},
|
||||
{
|
||||
title: t("environments.project.general.delete_project"),
|
||||
description: t("environments.project.general.delete_project_settings_description"),
|
||||
skeletonLines: [{ classes: "h-4 w-96" }, { classes: "h-8 w-24" }],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.configuration")}>
|
||||
<ProjectConfigNavigation activeId="general" loading />
|
||||
</PageHeader>
|
||||
{cards.map((card, index) => (
|
||||
<LoadingCard key={index} {...card} />
|
||||
))}
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import {
|
||||
getMultiLanguagePermission,
|
||||
getRoleManagementPermission,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { SettingsId } from "@/modules/ui/components/settings-id";
|
||||
import packageJson from "@/package.json";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
|
||||
import { DeleteProject } from "./components/delete-project";
|
||||
import { EditProjectNameForm } from "./components/edit-project-name-form";
|
||||
import { EditWaitingTimeForm } from "./components/edit-waiting-time-form";
|
||||
|
||||
export const GeneralSettingsPage = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
const [project, session, organization] = await Promise.all([
|
||||
getProjectByEnvironmentId(params.environmentId),
|
||||
getServerSession(authOptions),
|
||||
getOrganizationByEnvironmentId(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
|
||||
|
||||
const { isMember, isOwner, isManager } = getAccessFlags(currentUserMembership?.role);
|
||||
const { hasManageAccess } = getTeamPermissionFlags(projectPermission);
|
||||
|
||||
const isReadOnly = isMember && !hasManageAccess;
|
||||
|
||||
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
|
||||
const canDoRoleManagement = await getRoleManagementPermission(organization);
|
||||
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.configuration")}>
|
||||
<ProjectConfigNavigation
|
||||
environmentId={params.environmentId}
|
||||
activeId="general"
|
||||
isMultiLanguageAllowed={isMultiLanguageAllowed}
|
||||
canDoRoleManagement={canDoRoleManagement}
|
||||
/>
|
||||
</PageHeader>
|
||||
<SettingsCard
|
||||
title={t("common.project_name")}
|
||||
description={t("environments.project.general.project_name_settings_description")}>
|
||||
<EditProjectNameForm project={project} isReadOnly={isReadOnly} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title={t("environments.project.general.recontact_waiting_time")}
|
||||
description={t("environments.project.general.recontact_waiting_time_settings_description")}>
|
||||
<EditWaitingTimeForm project={project} isReadOnly={isReadOnly} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title={t("environments.project.general.delete_project")}
|
||||
description={t("environments.project.general.delete_project_settings_description")}>
|
||||
<DeleteProject
|
||||
environmentId={params.environmentId}
|
||||
project={project}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<div>
|
||||
<SettingsId title={t("common.project_id")} id={project.id}></SettingsId>
|
||||
{!IS_FORMBRICKS_CLOUD && (
|
||||
<SettingsId title={t("common.formbricks_version")} id={packageJson.version}></SettingsId>
|
||||
)}
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { Metadata } from "next";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Config",
|
||||
};
|
||||
|
||||
export const ProjectSettingsLayout = async (props) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
const [organization, session] = await Promise.all([
|
||||
getOrganizationByEnvironmentId(params.environmentId),
|
||||
getServerSession(authOptions),
|
||||
]);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||
const { isBilling } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
if (isBilling) {
|
||||
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||
}
|
||||
|
||||
const project = await getProjectByEnvironmentId(params.environmentId);
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { createHash, randomBytes } from "crypto";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { apiKeyCache } from "@formbricks/lib/apiKey/cache";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { TApiKey, TApiKeyCreateInput, ZApiKeyCreateInput } from "@formbricks/types/api-keys";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
export const deleteApiKey = async (id: string): Promise<TApiKey | null> => {
|
||||
validateInputs([id, ZId]);
|
||||
|
||||
try {
|
||||
const deletedApiKeyData = await prisma.apiKey.delete({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
});
|
||||
|
||||
apiKeyCache.revalidate({
|
||||
id: deletedApiKeyData.id,
|
||||
hashedKey: deletedApiKeyData.hashedKey,
|
||||
environmentId: deletedApiKeyData.environmentId,
|
||||
});
|
||||
|
||||
return deletedApiKeyData;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
|
||||
|
||||
export const createApiKey = async (
|
||||
environmentId: string,
|
||||
apiKeyData: TApiKeyCreateInput
|
||||
): Promise<TApiKey> => {
|
||||
validateInputs([environmentId, ZId], [apiKeyData, ZApiKeyCreateInput]);
|
||||
try {
|
||||
const key = randomBytes(16).toString("hex");
|
||||
const hashedKey = hashApiKey(key);
|
||||
|
||||
const result = await prisma.apiKey.create({
|
||||
data: {
|
||||
...apiKeyData,
|
||||
hashedKey,
|
||||
environment: { connect: { id: environmentId } },
|
||||
},
|
||||
});
|
||||
|
||||
apiKeyCache.revalidate({
|
||||
id: result.id,
|
||||
hashedKey: result.hashedKey,
|
||||
environmentId: result.environmentId,
|
||||
});
|
||||
|
||||
return { ...result, apiKey: key };
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,219 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { isS3Configured } from "@formbricks/lib/constants";
|
||||
import { environmentCache } from "@formbricks/lib/environment/cache";
|
||||
import { createEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { projectCache } from "@formbricks/lib/project/cache";
|
||||
import {
|
||||
deleteLocalFilesByEnvironmentId,
|
||||
deleteS3FilesByEnvironmentId,
|
||||
} from "@formbricks/lib/storage/service";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { ZId, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors";
|
||||
import { TProject, TProjectUpdateInput, ZProject, ZProjectUpdateInput } from "@formbricks/types/project";
|
||||
|
||||
const selectProject = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
organizationId: true,
|
||||
languages: true,
|
||||
recontactDays: true,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: true,
|
||||
placement: true,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: true,
|
||||
environments: true,
|
||||
styling: true,
|
||||
logo: true,
|
||||
};
|
||||
|
||||
export const updateProject = async (
|
||||
projectId: string,
|
||||
inputProject: TProjectUpdateInput
|
||||
): Promise<TProject> => {
|
||||
validateInputs([projectId, ZId], [inputProject, ZProjectUpdateInput]);
|
||||
const { environments, ...data } = inputProject;
|
||||
let updatedProject;
|
||||
try {
|
||||
updatedProject = await prisma.project.update({
|
||||
where: {
|
||||
id: projectId,
|
||||
},
|
||||
data: {
|
||||
...data,
|
||||
environments: {
|
||||
connect: environments?.map((environment) => ({ id: environment.id })) ?? [],
|
||||
},
|
||||
},
|
||||
select: selectProject,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const project = ZProject.parse(updatedProject);
|
||||
|
||||
projectCache.revalidate({
|
||||
id: project.id,
|
||||
organizationId: project.organizationId,
|
||||
});
|
||||
|
||||
project.environments.forEach((environment) => {
|
||||
// revalidate environment cache
|
||||
projectCache.revalidate({
|
||||
environmentId: environment.id,
|
||||
});
|
||||
});
|
||||
|
||||
return project;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
console.error(JSON.stringify(error.errors, null, 2));
|
||||
}
|
||||
throw new ValidationError("Data validation of project failed");
|
||||
}
|
||||
};
|
||||
|
||||
export const createProject = async (
|
||||
organizationId: string,
|
||||
projectInput: Partial<TProjectUpdateInput>
|
||||
): Promise<TProject> => {
|
||||
validateInputs([organizationId, ZString], [projectInput, ZProjectUpdateInput.partial()]);
|
||||
|
||||
if (!projectInput.name) {
|
||||
throw new ValidationError("Project Name is required");
|
||||
}
|
||||
|
||||
const { environments, teamIds, ...data } = projectInput;
|
||||
|
||||
try {
|
||||
let project = await prisma.project.create({
|
||||
data: {
|
||||
config: {
|
||||
channel: null,
|
||||
industry: null,
|
||||
},
|
||||
...data,
|
||||
name: projectInput.name,
|
||||
organizationId,
|
||||
},
|
||||
select: selectProject,
|
||||
});
|
||||
|
||||
if (teamIds) {
|
||||
await prisma.projectTeam.createMany({
|
||||
data: teamIds.map((teamId) => ({
|
||||
projectId: project.id,
|
||||
teamId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
projectCache.revalidate({
|
||||
id: project.id,
|
||||
organizationId: project.organizationId,
|
||||
});
|
||||
|
||||
const devEnvironment = await createEnvironment(project.id, {
|
||||
type: "development",
|
||||
});
|
||||
|
||||
const prodEnvironment = await createEnvironment(project.id, {
|
||||
type: "production",
|
||||
});
|
||||
|
||||
const updatedProject = await updateProject(project.id, {
|
||||
environments: [devEnvironment, prodEnvironment],
|
||||
});
|
||||
|
||||
return updatedProject;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
throw new InvalidInputError("A project with this name already exists in your organization");
|
||||
}
|
||||
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2002") {
|
||||
throw new InvalidInputError("A project with this name already exists in this organization");
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteProject = async (projectId: string): Promise<TProject> => {
|
||||
try {
|
||||
const project = await prisma.project.delete({
|
||||
where: {
|
||||
id: projectId,
|
||||
},
|
||||
select: selectProject,
|
||||
});
|
||||
|
||||
if (project) {
|
||||
// delete all files from storage related to this project
|
||||
|
||||
if (isS3Configured()) {
|
||||
const s3FilesPromises = project.environments.map(async (environment) => {
|
||||
return deleteS3FilesByEnvironmentId(environment.id);
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all(s3FilesPromises);
|
||||
} catch (err) {
|
||||
// fail silently because we don't want to throw an error if the files are not deleted
|
||||
console.error(err);
|
||||
}
|
||||
} else {
|
||||
const localFilesPromises = project.environments.map(async (environment) => {
|
||||
return deleteLocalFilesByEnvironmentId(environment.id);
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all(localFilesPromises);
|
||||
} catch (err) {
|
||||
// fail silently because we don't want to throw an error if the files are not deleted
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
projectCache.revalidate({
|
||||
id: project.id,
|
||||
organizationId: project.organizationId,
|
||||
});
|
||||
|
||||
environmentCache.revalidate({
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
project.environments.forEach((environment) => {
|
||||
// revalidate project cache
|
||||
projectCache.revalidate({
|
||||
environmentId: environment.id,
|
||||
});
|
||||
environmentCache.revalidate({
|
||||
id: environment.id,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return project;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import "server-only";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { tagCache } from "@formbricks/lib/tag/cache";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { ZId, ZString } from "@formbricks/types/common";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
|
||||
export const deleteTag = async (id: string): Promise<TTag> => {
|
||||
validateInputs([id, ZId]);
|
||||
|
||||
try {
|
||||
const tag = await prisma.tag.delete({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
tagCache.revalidate({
|
||||
id,
|
||||
environmentId: tag.environmentId,
|
||||
});
|
||||
|
||||
return tag;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateTagName = async (id: string, name: string): Promise<TTag> => {
|
||||
validateInputs([id, ZId], [name, ZString]);
|
||||
|
||||
try {
|
||||
const tag = await prisma.tag.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
tagCache.revalidate({
|
||||
id: tag.id,
|
||||
environmentId: tag.environmentId,
|
||||
});
|
||||
|
||||
return tag;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const mergeTags = async (originalTagId: string, newTagId: string): Promise<TTag | undefined> => {
|
||||
validateInputs([originalTagId, ZId], [newTagId, ZId]);
|
||||
|
||||
try {
|
||||
let originalTag: TTag | null;
|
||||
|
||||
originalTag = await prisma.tag.findUnique({
|
||||
where: {
|
||||
id: originalTagId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!originalTag) {
|
||||
throw new Error("Tag not found");
|
||||
}
|
||||
|
||||
let newTag: TTag | null;
|
||||
|
||||
newTag = await prisma.tag.findUnique({
|
||||
where: {
|
||||
id: newTagId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!newTag) {
|
||||
throw new Error("Tag not found");
|
||||
}
|
||||
|
||||
// finds all the responses that have both the tags
|
||||
let responsesWithBothTags = await prisma.response.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
tags: {
|
||||
some: {
|
||||
tagId: {
|
||||
in: [originalTagId],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
tags: {
|
||||
some: {
|
||||
tagId: {
|
||||
in: [newTagId],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!!responsesWithBothTags?.length) {
|
||||
await Promise.all(
|
||||
responsesWithBothTags.map(async (response) => {
|
||||
await prisma.$transaction([
|
||||
prisma.tagsOnResponses.deleteMany({
|
||||
where: {
|
||||
responseId: response.id,
|
||||
tagId: {
|
||||
in: [originalTagId, newTagId],
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
prisma.tagsOnResponses.create({
|
||||
data: {
|
||||
responseId: response.id,
|
||||
tagId: newTagId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
})
|
||||
);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.tagsOnResponses.updateMany({
|
||||
where: {
|
||||
tagId: originalTagId,
|
||||
},
|
||||
data: {
|
||||
tagId: newTagId,
|
||||
},
|
||||
}),
|
||||
|
||||
prisma.tag.delete({
|
||||
where: {
|
||||
id: originalTagId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return newTag;
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.tagsOnResponses.updateMany({
|
||||
where: {
|
||||
tagId: originalTagId,
|
||||
},
|
||||
data: {
|
||||
tagId: newTagId,
|
||||
},
|
||||
}),
|
||||
|
||||
prisma.tag.delete({
|
||||
where: {
|
||||
id: originalTagId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
tagCache.revalidate({
|
||||
id: originalTagId,
|
||||
environmentId: originalTag.environmentId,
|
||||
});
|
||||
|
||||
tagCache.revalidate({
|
||||
id: newTagId,
|
||||
});
|
||||
|
||||
return newTag;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { updateProjectAction } from "@/modules/projects/settings/actions";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { UpgradePlanNotice } from "@/modules/ui/components/upgrade-plan-notice";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TProject, TProjectUpdateInput } from "@formbricks/types/project";
|
||||
|
||||
interface EditBrandingProps {
|
||||
type: "linkSurvey" | "appSurvey";
|
||||
project: TProject;
|
||||
canRemoveBranding: boolean;
|
||||
environmentId: string;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
export const EditBranding = ({
|
||||
type,
|
||||
project,
|
||||
canRemoveBranding,
|
||||
environmentId,
|
||||
isReadOnly,
|
||||
}: EditBrandingProps) => {
|
||||
const t = useTranslations();
|
||||
const [isBrandingEnabled, setIsBrandingEnabled] = useState(
|
||||
type === "linkSurvey" ? project.linkSurveyBranding : project.inAppSurveyBranding
|
||||
);
|
||||
const [updatingBranding, setUpdatingBranding] = useState(false);
|
||||
|
||||
const toggleBranding = async () => {
|
||||
try {
|
||||
setUpdatingBranding(true);
|
||||
const newBrandingState = !isBrandingEnabled;
|
||||
setIsBrandingEnabled(newBrandingState);
|
||||
let inputProject: Partial<TProjectUpdateInput> = {
|
||||
[type === "linkSurvey" ? "linkSurveyBranding" : "inAppSurveyBranding"]: newBrandingState,
|
||||
};
|
||||
await updateProjectAction({ projectId: project.id, data: inputProject });
|
||||
toast.success(
|
||||
newBrandingState
|
||||
? t("environments.project.look.formbricks_branding_shown")
|
||||
: t("environments.project.look.formbricks_branding_hidden")
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setUpdatingBranding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full items-center space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id={`branding-${type}`}
|
||||
checked={isBrandingEnabled}
|
||||
onCheckedChange={toggleBranding}
|
||||
disabled={!canRemoveBranding || updatingBranding || isReadOnly}
|
||||
/>
|
||||
<Label htmlFor={`branding-${type}`}>
|
||||
{t("environments.project.look.show_formbricks_branding_in", {
|
||||
type: type === "linkSurvey" ? t("common.link") : t("common.app"),
|
||||
})}
|
||||
</Label>
|
||||
</div>
|
||||
{!canRemoveBranding && (
|
||||
<div>
|
||||
{type === "linkSurvey" && (
|
||||
<div className="mb-8">
|
||||
<UpgradePlanNotice
|
||||
message={t("environments.project.look.formbricks_branding_upgrade_message")}
|
||||
textForUrl={t("environments.project.look.formbricks_branding_upgrade_text")}
|
||||
url={`/environments/${environmentId}/settings/billing`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{type !== "linkSurvey" && (
|
||||
<UpgradePlanNotice
|
||||
message={t("environments.project.look.formbricks_branding_upgrade_message_in_app")}
|
||||
textForUrl={t("environments.project.look.formbricks_branding_upgrade_text")}
|
||||
url={`/environments/${environmentId}/settings/billing`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { handleFileUpload } from "@/app/lib/fileUpload";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { updateProjectAction } from "@/modules/projects/settings/actions";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ColorPicker } from "@/modules/ui/components/color-picker";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { FileInput } from "@/modules/ui/components/file-input";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
import { ChangeEvent, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TProject, TProjectUpdateInput } from "@formbricks/types/project";
|
||||
|
||||
interface EditLogoProps {
|
||||
project: TProject;
|
||||
environmentId: string;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const EditLogo = ({ project, environmentId, isReadOnly }: EditLogoProps) => {
|
||||
const t = useTranslations();
|
||||
const [logoUrl, setLogoUrl] = useState<string | undefined>(project.logo?.url || undefined);
|
||||
const [logoBgColor, setLogoBgColor] = useState<string | undefined>(project.logo?.bgColor || undefined);
|
||||
const [isBgColorEnabled, setIsBgColorEnabled] = useState<boolean>(!!project.logo?.bgColor);
|
||||
const [confirmRemoveLogoModalOpen, setConfirmRemoveLogoModalOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleImageUpload = async (file: File) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const uploadResult = await handleFileUpload(file, environmentId);
|
||||
if (uploadResult.error) {
|
||||
toast.error(uploadResult.error);
|
||||
return;
|
||||
}
|
||||
setLogoUrl(uploadResult.url);
|
||||
} catch (error) {
|
||||
toast.error(t("environments.project.look.logo_upload_failed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) await handleImageUpload(file);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const saveChanges = async () => {
|
||||
if (!isEditing) {
|
||||
setIsEditing(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const updatedProject: TProjectUpdateInput = {
|
||||
logo: { url: logoUrl, bgColor: isBgColorEnabled ? logoBgColor : undefined },
|
||||
};
|
||||
const updateProjectResponse = await updateProjectAction({
|
||||
projectId: project.id,
|
||||
data: updatedProject,
|
||||
});
|
||||
if (updateProjectResponse?.data) {
|
||||
toast.success(t("environments.project.look.logo_updated_successfully"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updateProjectResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("environments.project.look.failed_to_update_logo"));
|
||||
} finally {
|
||||
setIsEditing(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeLogo = async () => {
|
||||
setLogoUrl(undefined);
|
||||
if (!isEditing) {
|
||||
setIsEditing(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const updatedProject: TProjectUpdateInput = {
|
||||
logo: { url: undefined, bgColor: undefined },
|
||||
};
|
||||
const updateProjectResponse = await updateProjectAction({
|
||||
projectId: project.id,
|
||||
data: updatedProject,
|
||||
});
|
||||
if (updateProjectResponse?.data) {
|
||||
toast.success(t("environments.project.look.logo_removed_successfully"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updateProjectResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("environments.project.look.failed_to_remove_logo"));
|
||||
} finally {
|
||||
setIsEditing(false);
|
||||
setIsLoading(false);
|
||||
setConfirmRemoveLogoModalOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleBackgroundColor = (enabled: boolean) => {
|
||||
setIsBgColorEnabled(enabled);
|
||||
if (!enabled) {
|
||||
setLogoBgColor(undefined);
|
||||
} else if (!logoBgColor) {
|
||||
setLogoBgColor("#f8f8f8");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full space-y-8" id="edit-logo">
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
src={logoUrl}
|
||||
alt="Logo"
|
||||
width={256}
|
||||
height={56}
|
||||
style={{ backgroundColor: logoBgColor || undefined }}
|
||||
className="-mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
||||
/>
|
||||
) : (
|
||||
<FileInput
|
||||
id="logo-input"
|
||||
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
|
||||
environmentId={environmentId}
|
||||
onFileUpload={(files: string[]) => {
|
||||
setLogoUrl(files[0]);
|
||||
setIsEditing(true);
|
||||
}}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg, image/png, image/webp"
|
||||
className="hidden"
|
||||
disabled={isReadOnly}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
{isEditing && logoUrl && (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => fileInputRef.current?.click()} variant="secondary" size="sm">
|
||||
{t("environments.project.look.replace_logo")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="warn"
|
||||
size="sm"
|
||||
onClick={() => setConfirmRemoveLogoModalOpen(true)}
|
||||
disabled={!isEditing}>
|
||||
{t("environments.project.look.remove_logo")}
|
||||
</Button>
|
||||
</div>
|
||||
<AdvancedOptionToggle
|
||||
isChecked={isBgColorEnabled}
|
||||
onToggle={toggleBackgroundColor}
|
||||
htmlId="addBackgroundColor"
|
||||
title={t("environments.project.look.add_background_color")}
|
||||
description={t("environments.project.look.add_background_color_description")}
|
||||
childBorder
|
||||
customContainerClass="p-0"
|
||||
disabled={!isEditing}>
|
||||
{isBgColorEnabled && (
|
||||
<div className="px-2">
|
||||
<ColorPicker
|
||||
color={logoBgColor || "#f8f8f8"}
|
||||
onChange={setLogoBgColor}
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AdvancedOptionToggle>
|
||||
</>
|
||||
)}
|
||||
{logoUrl && (
|
||||
<Button onClick={saveChanges} disabled={isLoading || isReadOnly} size="sm">
|
||||
{isEditing ? t("common.save") : t("common.edit")}
|
||||
</Button>
|
||||
)}
|
||||
<DeleteDialog
|
||||
open={confirmRemoveLogoModalOpen}
|
||||
setOpen={setConfirmRemoveLogoModalOpen}
|
||||
deleteWhat={t("common.logo")}
|
||||
text={t("environments.project.look.remove_logo_confirmation")}
|
||||
onDelete={removeLogo}
|
||||
/>
|
||||
</div>
|
||||
{isReadOnly && (
|
||||
<Alert variant="warning" className="mt-4">
|
||||
<AlertDescription>
|
||||
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { updateProjectAction } from "@/modules/projects/settings/actions";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormProvider } from "@/modules/ui/components/form";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { getPlacementStyle } from "@/modules/ui/components/preview-survey/lib/utils";
|
||||
import { RadioGroup, RadioGroupItem } from "@/modules/ui/components/radio-group";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
|
||||
const placements = [
|
||||
{ name: "common.bottom_right", value: "bottomRight", disabled: false },
|
||||
{ name: "common.top_right", value: "topRight", disabled: false },
|
||||
{ name: "common.top_left", value: "topLeft", disabled: false },
|
||||
{ name: "common.bottom_left", value: "bottomLeft", disabled: false },
|
||||
{ name: "common.centered_modal", value: "center", disabled: false },
|
||||
];
|
||||
|
||||
interface EditPlacementProps {
|
||||
project: TProject;
|
||||
environmentId: string;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
const ZProjectPlacementInput = z.object({
|
||||
placement: z.enum(["bottomRight", "topRight", "topLeft", "bottomLeft", "center"]),
|
||||
darkOverlay: z.boolean(),
|
||||
clickOutsideClose: z.boolean(),
|
||||
});
|
||||
|
||||
type EditPlacementFormValues = z.infer<typeof ZProjectPlacementInput>;
|
||||
|
||||
export const EditPlacementForm = ({ project, isReadOnly }: EditPlacementProps) => {
|
||||
const t = useTranslations();
|
||||
const form = useForm<EditPlacementFormValues>({
|
||||
defaultValues: {
|
||||
placement: project.placement,
|
||||
darkOverlay: project.darkOverlay ?? false,
|
||||
clickOutsideClose: project.clickOutsideClose ?? false,
|
||||
},
|
||||
resolver: zodResolver(ZProjectPlacementInput),
|
||||
});
|
||||
|
||||
const currentPlacement = form.watch("placement");
|
||||
const darkOverlay = form.watch("darkOverlay");
|
||||
const clickOutsideClose = form.watch("clickOutsideClose");
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const overlayStyle = currentPlacement === "center" && darkOverlay ? "bg-slate-700/80" : "bg-slate-200";
|
||||
|
||||
const onSubmit: SubmitHandler<EditPlacementFormValues> = async (data) => {
|
||||
const updatedProjectResponse = await updateProjectAction({
|
||||
projectId: project.id,
|
||||
data: {
|
||||
placement: data.placement,
|
||||
darkOverlay: data.darkOverlay,
|
||||
clickOutsideClose: data.clickOutsideClose,
|
||||
},
|
||||
});
|
||||
if (updatedProjectResponse?.data) {
|
||||
toast.success(t("environments.project.look.placement_updated_successfully"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormProvider {...form}>
|
||||
<form className="w-full items-center" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="placement"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
disabled={isReadOnly}
|
||||
className="h-full">
|
||||
{placements.map((placement) => (
|
||||
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem
|
||||
id={placement.value}
|
||||
value={placement.value}
|
||||
disabled={placement.disabled}
|
||||
checked={field.value === placement.value}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={placement.value}
|
||||
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
|
||||
{t(placement.name)}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
clickOutsideClose ? "" : "cursor-not-allowed",
|
||||
"relative ml-8 h-40 w-full rounded",
|
||||
overlayStyle
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute h-16 w-16 cursor-default rounded bg-slate-700",
|
||||
getPlacementStyle(currentPlacement)
|
||||
)}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentPlacement === "center" && (
|
||||
<>
|
||||
<div className="mt-6 space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="darkOverlay"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="font-semibold">
|
||||
{t("environments.project.look.centered_modal_overlay_color")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value === "darkOverlay");
|
||||
}}
|
||||
disabled={isReadOnly}
|
||||
className="flex space-x-4">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="lightOverlay" value="lightOverlay" checked={!field.value} />
|
||||
<Label
|
||||
htmlFor="lightOverlay"
|
||||
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
|
||||
{t("common.light_overlay")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="darkOverlay" value="darkOverlay" checked={field.value} />
|
||||
<Label
|
||||
htmlFor="darkOverlay"
|
||||
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
|
||||
{t("common.dark_overlay")}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clickOutsideClose"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="font-semibold">
|
||||
{t("common.allow_users_to_exit_by_clicking_outside_the_survey")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
disabled={isReadOnly}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value === "allow");
|
||||
}}
|
||||
className="flex space-x-4">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="disallow" value="disallow" checked={!field.value} />
|
||||
<Label
|
||||
htmlFor="disallow"
|
||||
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
|
||||
{t("common.disallow")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="allow" value="allow" checked={field.value} />
|
||||
<Label
|
||||
htmlFor="allow"
|
||||
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
|
||||
{t("common.allow")}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button className="mt-4 w-fit" size="sm" loading={isSubmitting} disabled={isReadOnly}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
{isReadOnly && (
|
||||
<Alert variant="warning" className="mt-4">
|
||||
<AlertDescription>
|
||||
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { ClientLogo } from "@/modules/ui/components/client-logo";
|
||||
import { MediaBackground } from "@/modules/ui/components/media-background";
|
||||
import { Modal } from "@/modules/ui/components/preview-survey/components/modal";
|
||||
import { ResetProgressButton } from "@/modules/ui/components/reset-progress-button";
|
||||
import { SurveyInline } from "@/modules/ui/components/survey";
|
||||
import { Variants, motion } from "framer-motion";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Fragment, useRef, useState } from "react";
|
||||
import type { TProject } from "@formbricks/types/project";
|
||||
import { TSurvey, TSurveyType } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface ThemeStylingPreviewSurveyProps {
|
||||
survey: TSurvey;
|
||||
project: TProject;
|
||||
previewType: TSurveyType;
|
||||
setPreviewType: (type: TSurveyType) => void;
|
||||
}
|
||||
|
||||
const previewParentContainerVariant: Variants = {
|
||||
expanded: {
|
||||
position: "fixed",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
||||
backdropFilter: "blur(15px)",
|
||||
left: 0,
|
||||
top: 0,
|
||||
zIndex: 1040,
|
||||
transition: {
|
||||
ease: "easeIn",
|
||||
duration: 0.001,
|
||||
},
|
||||
},
|
||||
shrink: {
|
||||
display: "none",
|
||||
position: "fixed",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.0)",
|
||||
backdropFilter: "blur(0px)",
|
||||
transition: {
|
||||
duration: 0,
|
||||
},
|
||||
zIndex: -1,
|
||||
},
|
||||
};
|
||||
|
||||
export const ThemeStylingPreviewSurvey = ({
|
||||
survey,
|
||||
project,
|
||||
previewType,
|
||||
setPreviewType,
|
||||
}: ThemeStylingPreviewSurveyProps) => {
|
||||
const [isFullScreenPreview] = useState(false);
|
||||
const [previewPosition] = useState("relative");
|
||||
const ContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const [shrink] = useState(false);
|
||||
const t = useTranslations();
|
||||
const { projectOverwrites } = survey || {};
|
||||
|
||||
const previewScreenVariants: Variants = {
|
||||
expanded: {
|
||||
right: "5%",
|
||||
bottom: "10%",
|
||||
top: "12%",
|
||||
width: "40%",
|
||||
position: "fixed",
|
||||
height: "80%",
|
||||
zIndex: 1050,
|
||||
boxShadow: "0px 4px 5px 4px rgba(169, 169, 169, 0.25)",
|
||||
transition: {
|
||||
ease: "easeInOut",
|
||||
duration: shrink ? 0.3 : 0,
|
||||
},
|
||||
},
|
||||
expanded_with_fixed_positioning: {
|
||||
zIndex: 1050,
|
||||
position: "fixed",
|
||||
top: "5%",
|
||||
right: "5%",
|
||||
bottom: "10%",
|
||||
width: "90%",
|
||||
height: "90%",
|
||||
transition: {
|
||||
ease: "easeOut",
|
||||
duration: 0.4,
|
||||
},
|
||||
},
|
||||
shrink: {
|
||||
display: "relative",
|
||||
width: ["83.33%"],
|
||||
height: ["95%"],
|
||||
},
|
||||
};
|
||||
|
||||
const { placement: surveyPlacement } = projectOverwrites || {};
|
||||
const { darkOverlay: surveyDarkOverlay } = projectOverwrites || {};
|
||||
const { clickOutsideClose: surveyClickOutsideClose } = projectOverwrites || {};
|
||||
|
||||
const placement = surveyPlacement || project.placement;
|
||||
const darkOverlay = surveyDarkOverlay ?? project.darkOverlay;
|
||||
const clickOutsideClose = surveyClickOutsideClose ?? project.clickOutsideClose;
|
||||
|
||||
const highlightBorderColor = project.styling.highlightBorderColor?.light;
|
||||
const [surveyFormKey, setSurveyFormKey] = useState<number>(Date.now());
|
||||
|
||||
const resetQuestionProgress = () => {
|
||||
setSurveyFormKey(Date.now());
|
||||
};
|
||||
|
||||
const isAppSurvey = previewType === "app";
|
||||
|
||||
const scrollToEditLogoSection = () => {
|
||||
const editLogoSection = document.getElementById("edit-logo");
|
||||
if (editLogoSection) {
|
||||
editLogoSection.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-items-center overflow-hidden">
|
||||
<motion.div
|
||||
variants={previewParentContainerVariant}
|
||||
className="fixed hidden h-[95%] w-5/6"
|
||||
animate={isFullScreenPreview ? "expanded" : "shrink"}
|
||||
/>
|
||||
<motion.div
|
||||
layout
|
||||
variants={previewScreenVariants}
|
||||
animate={
|
||||
isFullScreenPreview
|
||||
? previewPosition === "relative"
|
||||
? "expanded"
|
||||
: "expanded_with_fixed_positioning"
|
||||
: "shrink"
|
||||
}
|
||||
className="relative flex h-[95%] max-h-[95%] w-5/6 items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
|
||||
<div className="flex h-full w-5/6 flex-1 flex-col">
|
||||
<div className="flex h-8 w-full items-center rounded-t-lg bg-slate-100">
|
||||
<div className="ml-6 flex space-x-2">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
|
||||
</div>
|
||||
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
|
||||
<p>{isAppSurvey ? "Your web app" : "Preview"}</p>
|
||||
|
||||
<div className="flex items-center">
|
||||
<ResetProgressButton onClick={resetQuestionProgress} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAppSurvey ? (
|
||||
<Modal
|
||||
isOpen
|
||||
placement={placement}
|
||||
clickOutsideClose={clickOutsideClose}
|
||||
darkOverlay={darkOverlay}
|
||||
previewMode="desktop"
|
||||
background={project.styling.cardBackgroundColor?.light}
|
||||
borderRadius={project.styling.roundness ?? 8}>
|
||||
<Fragment key={surveyFormKey}>
|
||||
<SurveyInline
|
||||
survey={{ ...survey, type: "app" }}
|
||||
isBrandingEnabled={project.inAppSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
onFileUpload={async (file) => file.name}
|
||||
styling={project.styling}
|
||||
isCardBorderVisible={!highlightBorderColor}
|
||||
languageCode="default"
|
||||
/>
|
||||
</Fragment>
|
||||
</Modal>
|
||||
) : (
|
||||
<MediaBackground survey={survey} project={project} ContentRef={ContentRef} isEditorView>
|
||||
{!project.styling?.isLogoHidden && (
|
||||
<div className="absolute left-5 top-5" onClick={scrollToEditLogoSection}>
|
||||
<ClientLogo project={project} previewSurvey />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
key={surveyFormKey}
|
||||
className={`${project.logo?.url && !project.styling.isLogoHidden && !isFullScreenPreview ? "mt-12" : ""} z-0 w-full max-w-md rounded-lg p-4`}>
|
||||
<SurveyInline
|
||||
survey={{ ...survey, type: "link" }}
|
||||
isBrandingEnabled={project.linkSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
onFileUpload={async (file) => file.name}
|
||||
responseCount={42}
|
||||
styling={project.styling}
|
||||
languageCode="default"
|
||||
/>
|
||||
</div>
|
||||
</MediaBackground>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* for toggling between mobile and desktop mode */}
|
||||
<div className="mt-2 flex rounded-full border-2 border-slate-300 p-1">
|
||||
<div
|
||||
className={`${previewType === "link" ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
|
||||
onClick={() => setPreviewType("link")}>
|
||||
{t("common.link_survey")}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${isAppSurvey ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
|
||||
onClick={() => setPreviewType("app")}>
|
||||
{t("common.app_survey")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import { BackgroundStylingCard } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/BackgroundStylingCard";
|
||||
import { CardStylingSettings } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CardStylingSettings";
|
||||
import { FormStylingSettings } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/FormStylingSettings";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { updateProjectAction } from "@/modules/projects/settings/actions";
|
||||
import { ThemeStylingPreviewSurvey } from "@/modules/projects/settings/look/components/theme-styling-preview-survey";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormProvider,
|
||||
} from "@/modules/ui/components/form";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { RotateCcwIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useState } from "react";
|
||||
import { SubmitHandler, UseFormReturn, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { COLOR_DEFAULTS, getPreviewSurvey } from "@formbricks/lib/styling/constants";
|
||||
import { TProject, TProjectStyling, ZProjectStyling } from "@formbricks/types/project";
|
||||
import { TSurvey, TSurveyStyling, TSurveyType } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface ThemeStylingProps {
|
||||
project: TProject;
|
||||
environmentId: string;
|
||||
colors: string[];
|
||||
isUnsplashConfigured: boolean;
|
||||
locale: string;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const ThemeStyling = ({
|
||||
project,
|
||||
environmentId,
|
||||
colors,
|
||||
isUnsplashConfigured,
|
||||
locale,
|
||||
isReadOnly,
|
||||
}: ThemeStylingProps) => {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<TProjectStyling>({
|
||||
defaultValues: {
|
||||
...project.styling,
|
||||
|
||||
// specify the default values for the colors
|
||||
allowStyleOverwrite: project.styling.allowStyleOverwrite ?? true,
|
||||
brandColor: { light: project.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor },
|
||||
questionColor: { light: project.styling.questionColor?.light ?? COLOR_DEFAULTS.questionColor },
|
||||
inputColor: { light: project.styling.inputColor?.light ?? COLOR_DEFAULTS.inputColor },
|
||||
inputBorderColor: { light: project.styling.inputBorderColor?.light ?? COLOR_DEFAULTS.inputBorderColor },
|
||||
cardBackgroundColor: {
|
||||
light: project.styling.cardBackgroundColor?.light ?? COLOR_DEFAULTS.cardBackgroundColor,
|
||||
},
|
||||
cardBorderColor: { light: project.styling.cardBorderColor?.light ?? COLOR_DEFAULTS.cardBorderColor },
|
||||
cardShadowColor: { light: project.styling.cardShadowColor?.light ?? COLOR_DEFAULTS.cardShadowColor },
|
||||
highlightBorderColor: project.styling.highlightBorderColor?.light
|
||||
? {
|
||||
light: project.styling.highlightBorderColor.light,
|
||||
}
|
||||
: undefined,
|
||||
isDarkModeEnabled: project.styling.isDarkModeEnabled ?? false,
|
||||
roundness: project.styling.roundness ?? 8,
|
||||
cardArrangement: project.styling.cardArrangement ?? {
|
||||
linkSurveys: "straight",
|
||||
appSurveys: "straight",
|
||||
},
|
||||
background: project.styling.background,
|
||||
hideProgressBar: project.styling.hideProgressBar ?? false,
|
||||
isLogoHidden: project.styling.isLogoHidden ?? false,
|
||||
},
|
||||
resolver: zodResolver(ZProjectStyling),
|
||||
});
|
||||
|
||||
const [previewSurveyType, setPreviewSurveyType] = useState<TSurveyType>("link");
|
||||
const [confirmResetStylingModalOpen, setConfirmResetStylingModalOpen] = useState(false);
|
||||
|
||||
const [formStylingOpen, setFormStylingOpen] = useState(false);
|
||||
const [cardStylingOpen, setCardStylingOpen] = useState(false);
|
||||
const [backgroundStylingOpen, setBackgroundStylingOpen] = useState(false);
|
||||
|
||||
const onReset = useCallback(async () => {
|
||||
const defaultStyling: TProjectStyling = {
|
||||
allowStyleOverwrite: true,
|
||||
brandColor: {
|
||||
light: COLOR_DEFAULTS.brandColor,
|
||||
},
|
||||
questionColor: {
|
||||
light: COLOR_DEFAULTS.questionColor,
|
||||
},
|
||||
inputColor: {
|
||||
light: COLOR_DEFAULTS.inputColor,
|
||||
},
|
||||
inputBorderColor: {
|
||||
light: COLOR_DEFAULTS.inputBorderColor,
|
||||
},
|
||||
cardBackgroundColor: {
|
||||
light: COLOR_DEFAULTS.cardBackgroundColor,
|
||||
},
|
||||
cardBorderColor: {
|
||||
light: COLOR_DEFAULTS.cardBorderColor,
|
||||
},
|
||||
isLogoHidden: false,
|
||||
highlightBorderColor: undefined,
|
||||
isDarkModeEnabled: false,
|
||||
background: {
|
||||
bg: "#fff",
|
||||
bgType: "color",
|
||||
},
|
||||
roundness: 8,
|
||||
cardArrangement: {
|
||||
linkSurveys: "straight",
|
||||
appSurveys: "straight",
|
||||
},
|
||||
};
|
||||
|
||||
const updatedProjectResponse = await updateProjectAction({
|
||||
projectId: project.id,
|
||||
data: {
|
||||
styling: { ...defaultStyling },
|
||||
},
|
||||
});
|
||||
|
||||
if (updatedProjectResponse?.data) {
|
||||
form.reset({ ...defaultStyling });
|
||||
toast.success(t("environments.project.look.styling_updated_successfully"));
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
}, [form, project.id, router]);
|
||||
|
||||
const onSubmit: SubmitHandler<TProjectStyling> = async (data) => {
|
||||
const updatedProjectResponse = await updateProjectAction({
|
||||
projectId: project.id,
|
||||
data: {
|
||||
styling: data,
|
||||
},
|
||||
});
|
||||
|
||||
if (updatedProjectResponse?.data) {
|
||||
form.reset({ ...updatedProjectResponse.data.styling });
|
||||
toast.success(t("environments.project.look.styling_updated_successfully"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
if (isReadOnly) {
|
||||
return (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex">
|
||||
{/* Styling settings */}
|
||||
<div className="relative flex w-1/2 flex-col pr-6">
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 rounded-lg bg-slate-50 p-4">
|
||||
<div className="flex items-center gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allowStyleOverwrite"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex w-full items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<div>
|
||||
<FormLabel>{t("environments.project.look.enable_custom_styling")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("environments.project.look.enable_custom_styling_description")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-lg bg-slate-50 p-4">
|
||||
<FormStylingSettings
|
||||
open={formStylingOpen}
|
||||
setOpen={setFormStylingOpen}
|
||||
isSettingsPage
|
||||
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
|
||||
/>
|
||||
|
||||
<CardStylingSettings
|
||||
open={cardStylingOpen}
|
||||
setOpen={setCardStylingOpen}
|
||||
isSettingsPage
|
||||
project={project}
|
||||
surveyType={previewSurveyType}
|
||||
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
|
||||
/>
|
||||
|
||||
<BackgroundStylingCard
|
||||
open={backgroundStylingOpen}
|
||||
setOpen={setBackgroundStylingOpen}
|
||||
environmentId={environmentId}
|
||||
colors={colors}
|
||||
key={form.watch("background.bg")}
|
||||
isSettingsPage
|
||||
isUnsplashConfigured={isUnsplashConfigured}
|
||||
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Button size="sm" type="submit">
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="minimal"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setConfirmResetStylingModalOpen(true)}>
|
||||
{t("common.reset_to_default")}
|
||||
<RotateCcwIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Survey Preview */}
|
||||
|
||||
<div className="relative w-1/2 rounded-lg bg-slate-100 pt-4">
|
||||
<div className="sticky top-4 mb-4 h-[600px]">
|
||||
<ThemeStylingPreviewSurvey
|
||||
survey={getPreviewSurvey(locale) as TSurvey}
|
||||
project={{
|
||||
...project,
|
||||
styling: form.getValues(),
|
||||
}}
|
||||
previewType={previewSurveyType}
|
||||
setPreviewType={setPreviewSurveyType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm reset styling modal */}
|
||||
<AlertDialog
|
||||
open={confirmResetStylingModalOpen}
|
||||
setOpen={setConfirmResetStylingModalOpen}
|
||||
headerText={t("environments.project.look.reset_styling")}
|
||||
mainText={t("environments.project.look.reset_styling_confirmation")}
|
||||
confirmBtnLabel={t("common.confirm")}
|
||||
onConfirm={() => {
|
||||
onReset();
|
||||
setConfirmResetStylingModalOpen(false);
|
||||
}}
|
||||
onDecline={() => setConfirmResetStylingModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { RadioGroup, RadioGroupItem } from "@/modules/ui/components/radio-group";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
|
||||
const placements = [
|
||||
{ name: "common.bottom_right", value: "bottomRight", disabled: false },
|
||||
{ name: "common.top_right", value: "topRight", disabled: false },
|
||||
{ name: "common.top_left", value: "topLeft", disabled: false },
|
||||
{ name: "common.bottom_left", value: "bottomLeft", disabled: false },
|
||||
{ name: "common.centered_modal", value: "center", disabled: false },
|
||||
];
|
||||
|
||||
export const ProjectLookSettingsLoading = () => {
|
||||
const t = useTranslations();
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.configuration")}>
|
||||
<ProjectConfigNavigation activeId="look" loading />
|
||||
</PageHeader>
|
||||
<SettingsCard
|
||||
title={t("environments.project.look.theme")}
|
||||
className="max-w-7xl"
|
||||
description={t("environments.project.look.theme_settings_description")}>
|
||||
<div className="flex animate-pulse">
|
||||
<div className="w-1/2">
|
||||
<div className="flex flex-col gap-4 pr-6">
|
||||
<div className="flex flex-col gap-4 rounded-lg bg-slate-50 p-4">
|
||||
<div className="flex items-center gap-6">
|
||||
<Switch />
|
||||
<div className="flex flex-col">
|
||||
<h3 className="text-sm font-semibold text-slate-700">
|
||||
{t("environments.project.look.enable_custom_styling")}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("environments.project.look.enable_custom_styling_description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 bg-slate-50 p-4">
|
||||
<div className="w-full rounded-lg border border-slate-300 bg-white">
|
||||
<div className="flex flex-col p-4">
|
||||
<h2 className="text-sm font-semibold text-slate-700">
|
||||
{t("environments.surveys.edit.form_styling")}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{t("environments.surveys.edit.style_the_question_texts_descriptions_and_input_fields")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full rounded-lg border border-slate-300 bg-white">
|
||||
<div className="flex flex-col p-4">
|
||||
<h2 className="text-sm font-semibold text-slate-700">
|
||||
{t("environments.surveys.edit.card_styling")}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{t("environments.surveys.edit.style_the_survey_card")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full rounded-lg border border-slate-300 bg-white">
|
||||
<div className="flex flex-col p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold text-slate-700">
|
||||
{t("environments.surveys.edit.background_styling")}
|
||||
</h2>
|
||||
<Badge text={t("common.link_surveys")} type="gray" size="normal" />
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{t("environments.surveys.edit.change_the_background_to_a_color_image_or_animation")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex w-1/2 flex-row items-center justify-center rounded-lg bg-slate-100 pt-4">
|
||||
<div className="relative mb-3 flex h-fit w-5/6 items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
|
||||
<div className="flex h-[90%] max-h-[90%] w-4/6 flex-1 flex-col">
|
||||
<div className="flex h-8 w-full items-center rounded-t-lg bg-slate-100">
|
||||
<div className="ml-6 flex space-x-2">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
|
||||
</div>
|
||||
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
|
||||
<p>{t("common.preview")}</p>
|
||||
|
||||
<div className="flex items-center pr-6">{t("common.restart")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid h-[500px] place-items-center bg-white">
|
||||
<h1 className="text-xl font-semibold text-slate-700">{t("common.loading")}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="Logo" description="Upload your company logo to brand surveys and link previews.">
|
||||
<div className="w-full animate-pulse items-center">
|
||||
<div className="relative flex h-52 w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:hover:border-slate-500 dark:hover:bg-slate-800">
|
||||
<p className="text-xl font-semibold text-slate-700">{t("common.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="In-app Survey Placement"
|
||||
description="Change where surveys will be shown in your web app.">
|
||||
<div className="w-full items-center">
|
||||
<div className="flex cursor-not-allowed select-none">
|
||||
<RadioGroup>
|
||||
{placements.map((placement) => (
|
||||
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem
|
||||
className="cursor-not-allowed select-none"
|
||||
id={placement.value}
|
||||
value={placement.value}
|
||||
disabled={placement.disabled}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={placement.value}
|
||||
className={cn(
|
||||
placement.disabled ? "cursor-not-allowed text-slate-500" : "text-slate-900"
|
||||
)}>
|
||||
{t(placement.name)}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<div className="relative ml-8 h-40 w-full rounded bg-slate-200">
|
||||
<div className={cn("absolute bottom-3 h-16 w-16 rounded bg-slate-700 sm:right-3")}></div>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="pointer-events-none mt-4 animate-pulse cursor-not-allowed select-none bg-slate-200">
|
||||
{t("common.loading")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Formbricks Signature"
|
||||
description="We love your support but understand if you toggle it off.">
|
||||
<div className="w-full items-center">
|
||||
<div className="pointer-events-none flex cursor-not-allowed select-none items-center space-x-2">
|
||||
<Switch id="signature" checked={false} />
|
||||
<Label htmlFor="signature">{t("environments.project.look.show_powered_by_formbricks")}</Label>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import {
|
||||
getMultiLanguagePermission,
|
||||
getRemoveInAppBrandingPermission,
|
||||
getRemoveLinkBrandingPermission,
|
||||
getRoleManagementPermission,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
|
||||
import { EditLogo } from "@/modules/projects/settings/look/components/edit-logo";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { DEFAULT_LOCALE, SURVEY_BG_COLORS, UNSPLASH_ACCESS_KEY } from "@formbricks/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
|
||||
import { getUserLocale } from "@formbricks/lib/user/service";
|
||||
import { EditBranding } from "./components/edit-branding";
|
||||
import { EditPlacementForm } from "./components/edit-placement-form";
|
||||
import { ThemeStyling } from "./components/theme-styling";
|
||||
|
||||
export const ProjectLookSettingsPage = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
const [session, organization, project] = await Promise.all([
|
||||
getServerSession(authOptions),
|
||||
getOrganizationByEnvironmentId(params.environmentId),
|
||||
getProjectByEnvironmentId(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
const locale = session?.user.id ? await getUserLocale(session.user.id) : undefined;
|
||||
const canRemoveInAppBranding = getRemoveInAppBrandingPermission(organization);
|
||||
const canRemoveLinkBranding = getRemoveLinkBrandingPermission(organization);
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const { isMember } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
|
||||
const { hasManageAccess } = getTeamPermissionFlags(projectPermission);
|
||||
|
||||
const isReadOnly = isMember && !hasManageAccess;
|
||||
|
||||
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
|
||||
const canDoRoleManagement = await getRoleManagementPermission(organization);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.configuration")}>
|
||||
<ProjectConfigNavigation
|
||||
environmentId={params.environmentId}
|
||||
activeId="look"
|
||||
isMultiLanguageAllowed={isMultiLanguageAllowed}
|
||||
canDoRoleManagement={canDoRoleManagement}
|
||||
/>
|
||||
</PageHeader>
|
||||
<SettingsCard
|
||||
title={t("environments.project.look.theme")}
|
||||
className={cn(!isReadOnly && "max-w-7xl")}
|
||||
description={t("environments.project.look.theme_settings_description")}>
|
||||
<ThemeStyling
|
||||
environmentId={params.environmentId}
|
||||
project={project}
|
||||
colors={SURVEY_BG_COLORS}
|
||||
isUnsplashConfigured={UNSPLASH_ACCESS_KEY ? true : false}
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title={t("common.logo")}
|
||||
description={t("environments.project.look.logo_settings_description")}>
|
||||
<EditLogo project={project} environmentId={params.environmentId} isReadOnly={isReadOnly} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title={t("environments.project.look.app_survey_placement")}
|
||||
description={t("environments.project.look.app_survey_placement_settings_description")}>
|
||||
<EditPlacementForm project={project} environmentId={params.environmentId} isReadOnly={isReadOnly} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title={t("environments.project.look.formbricks_branding")}
|
||||
description={t("environments.project.look.formbricks_branding_settings_description")}>
|
||||
<div className="space-y-4">
|
||||
<EditBranding
|
||||
type="linkSurvey"
|
||||
project={project}
|
||||
canRemoveBranding={canRemoveLinkBranding}
|
||||
environmentId={params.environmentId}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
<EditBranding
|
||||
type="appSurvey"
|
||||
project={project}
|
||||
canRemoveBranding={canRemoveInAppBranding}
|
||||
environmentId={params.environmentId}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isReadOnly && (
|
||||
<Alert variant="warning" className="mt-4">
|
||||
<AlertDescription>
|
||||
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</SettingsCard>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const ProjectSettingsPage = async (props) => {
|
||||
const params = await props.params;
|
||||
return redirect(`/environments/${params.environmentId}/project/general`);
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
"use server";
|
||||
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import {
|
||||
getEnvironmentIdFromTagId,
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
getOrganizationIdFromTagId,
|
||||
getProjectIdFromEnvironmentId,
|
||||
getProjectIdFromTagId,
|
||||
} from "@/lib/utils/helper";
|
||||
import { deleteTag, mergeTags, updateTagName } from "@/modules/projects/settings/lib/tag";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
const ZDeleteTagAction = z.object({
|
||||
tagId: ZId,
|
||||
});
|
||||
|
||||
export const deleteTagAction = authenticatedActionClient
|
||||
.schema(ZDeleteTagAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromTagId(parsedInput.tagId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromTagId(parsedInput.tagId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await deleteTag(parsedInput.tagId);
|
||||
});
|
||||
|
||||
const ZUpdateTagNameAction = z.object({
|
||||
tagId: ZId,
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const updateTagNameAction = authenticatedActionClient
|
||||
.schema(ZUpdateTagNameAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromTagId(parsedInput.tagId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromTagId(parsedInput.tagId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await updateTagName(parsedInput.tagId, parsedInput.name);
|
||||
});
|
||||
|
||||
const ZMergeTagsAction = z.object({
|
||||
originalTagId: ZId,
|
||||
newTagId: ZId,
|
||||
});
|
||||
|
||||
export const mergeTagsAction = authenticatedActionClient
|
||||
.schema(ZMergeTagsAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const originalTagEnvironmentId = await getEnvironmentIdFromTagId(parsedInput.originalTagId);
|
||||
const newTagEnvironmentId = await getEnvironmentIdFromTagId(parsedInput.newTagId);
|
||||
|
||||
if (originalTagEnvironmentId !== newTagEnvironmentId) {
|
||||
throw new Error("Tags must be in the same environment");
|
||||
}
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(newTagEnvironmentId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromEnvironmentId(newTagEnvironmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await mergeTags(parsedInput.originalTagId, parsedInput.newTagId);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { SingleTag } from "@/modules/projects/settings/tags/components/single-tag";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TTag, TTagsCount } from "@formbricks/types/tags";
|
||||
|
||||
interface EditTagsWrapperProps {
|
||||
environment: TEnvironment;
|
||||
environmentTags: TTag[];
|
||||
environmentTagsCount: TTagsCount;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const EditTagsWrapper: React.FC<EditTagsWrapperProps> = (props) => {
|
||||
const t = useTranslations();
|
||||
const { environment, environmentTags, environmentTagsCount, isReadOnly } = props;
|
||||
return (
|
||||
<div className="">
|
||||
<div className="grid grid-cols-4 content-center rounded-lg bg-white text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-2">{t("environments.project.tags.tag")}</div>
|
||||
<div className="col-span-1 text-center">{t("environments.project.tags.count")}</div>
|
||||
{!isReadOnly && (
|
||||
<div className="col-span-1 flex justify-center text-center">{t("common.actions")}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!environmentTags?.length ? (
|
||||
<EmptySpaceFiller environment={environment} type="tag" noWidgetRequired />
|
||||
) : null}
|
||||
|
||||
{environmentTags?.map((tag) => (
|
||||
<SingleTag
|
||||
key={tag.id}
|
||||
tagId={tag.id}
|
||||
tagName={tag.name}
|
||||
tagCount={environmentTagsCount?.find((count) => count.tagId === tag.id)?.count ?? 0}
|
||||
environmentTags={environmentTags}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/modules/ui/components/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
|
||||
interface MergeTagsComboboxProps {
|
||||
tags: Tag[];
|
||||
onSelect: (tagId: string) => void;
|
||||
}
|
||||
|
||||
type Tag = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const MergeTagsCombobox = ({ tags, onSelect }: MergeTagsComboboxProps) => {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="font-medium text-slate-900 focus:border-transparent focus:shadow-transparent focus:outline-transparent focus:ring-0 focus:ring-transparent">
|
||||
{t("environments.project.tags.merge")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="max-h-60 w-[200px] overflow-y-auto p-0">
|
||||
<Command>
|
||||
<div className="p-1">
|
||||
<CommandInput
|
||||
placeholder={t("environments.project.tags.search_tags")}
|
||||
className="border-b border-none border-transparent shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
|
||||
/>
|
||||
</div>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<div className="p-2 text-sm text-slate-500">{t("environments.project.tags.no_tag_found")}</div>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tags?.length === 0 ? (
|
||||
<CommandItem>{t("environments.project.tags.no_tag_found")}</CommandItem>
|
||||
) : null}
|
||||
|
||||
{tags?.map((tag) => (
|
||||
<CommandItem
|
||||
key={tag.value}
|
||||
onSelect={(currentValue) => {
|
||||
setValue(currentValue === value ? "" : currentValue);
|
||||
setOpen(false);
|
||||
onSelect(tag.value);
|
||||
}}>
|
||||
{tag.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import {
|
||||
deleteTagAction,
|
||||
mergeTagsAction,
|
||||
updateTagNameAction,
|
||||
} from "@/modules/projects/settings/tags/actions";
|
||||
import { MergeTagsCombobox } from "@/modules/projects/settings/tags/components/merge-tags-combobox";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
import { AlertCircleIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
|
||||
interface SingleTagProps {
|
||||
tagId: string;
|
||||
tagName: string;
|
||||
tagCount?: number;
|
||||
tagCountLoading?: boolean;
|
||||
updateTagsCount?: () => void;
|
||||
environmentTags: TTag[];
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
export const SingleTag: React.FC<SingleTagProps> = ({
|
||||
tagId,
|
||||
tagName,
|
||||
tagCount = 0,
|
||||
tagCountLoading = false,
|
||||
updateTagsCount = () => {},
|
||||
environmentTags,
|
||||
isReadOnly = false,
|
||||
}) => {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const [updateTagError, setUpdateTagError] = useState(false);
|
||||
const [isMergingTags, setIsMergingTags] = useState(false);
|
||||
const [openDeleteTagDialog, setOpenDeleteTagDialog] = useState(false);
|
||||
|
||||
const confirmDeleteTag = async () => {
|
||||
const deleteTagResponse = await deleteTagAction({ tagId });
|
||||
if (deleteTagResponse?.data) {
|
||||
toast.success(t("environments.project.tags.tag_deleted"));
|
||||
updateTagsCount();
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(deleteTagResponse);
|
||||
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full" key={tagId}>
|
||||
<div className="grid h-16 grid-cols-4 content-center rounded-lg">
|
||||
<div className="col-span-2 flex items-center text-sm">
|
||||
<div className="w-full text-left">
|
||||
<Input
|
||||
disabled={isReadOnly}
|
||||
className={cn(
|
||||
"w-full border font-medium text-slate-900",
|
||||
updateTagError
|
||||
? "border-red-500 focus:border-red-500"
|
||||
: "border-slate-200 focus:border-slate-500"
|
||||
)}
|
||||
defaultValue={tagName}
|
||||
onBlur={(e) => {
|
||||
updateTagNameAction({ tagId, name: e.target.value.trim() }).then((updateTagNameResponse) => {
|
||||
if (updateTagNameResponse?.data) {
|
||||
setUpdateTagError(false);
|
||||
toast.success(t("environments.project.tags.tag_updated"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updateTagNameResponse);
|
||||
if (
|
||||
errorMessage.includes(
|
||||
t("environments.project.tags.unique_constraint_failed_on_the_fields")
|
||||
)
|
||||
) {
|
||||
toast.error(t("environments.project.tags.tag_already_exists"), {
|
||||
duration: 2000,
|
||||
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
|
||||
});
|
||||
} else {
|
||||
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"), {
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
setUpdateTagError(true);
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="text-slate-900">{tagCountLoading ? <LoadingSpinner /> : <p>{tagCount}</p>}</div>
|
||||
</div>
|
||||
|
||||
{!isReadOnly && (
|
||||
<div className="col-span-1 my-auto flex items-center justify-center gap-2 whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div>
|
||||
{isMergingTags ? (
|
||||
<div className="w-24">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : (
|
||||
<MergeTagsCombobox
|
||||
tags={
|
||||
environmentTags
|
||||
?.filter((tag) => tag.id !== tagId)
|
||||
?.map((tag) => ({ label: tag.name, value: tag.id })) ?? []
|
||||
}
|
||||
onSelect={(newTagId) => {
|
||||
setIsMergingTags(true);
|
||||
mergeTagsAction({ originalTagId: tagId, newTagId }).then((mergeTagsResponse) => {
|
||||
if (mergeTagsResponse?.data) {
|
||||
toast.success(t("environments.project.tags.tags_merged"));
|
||||
updateTagsCount();
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(mergeTagsResponse);
|
||||
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
setIsMergingTags(false);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="alert"
|
||||
size="sm"
|
||||
// loading={isDeletingTag}
|
||||
className="font-medium text-slate-50 focus:border-transparent focus:shadow-transparent focus:outline-transparent focus:ring-0 focus:ring-transparent"
|
||||
onClick={() => setOpenDeleteTagDialog(true)}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
<DeleteDialog
|
||||
open={openDeleteTagDialog}
|
||||
setOpen={setOpenDeleteTagDialog}
|
||||
deleteWhat={tagName}
|
||||
text={t("environments.project.tags.delete_tag_confirmation")}
|
||||
onDelete={confirmDeleteTag}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export const TagsLoading = () => {
|
||||
const t = useTranslations();
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.configuration")}>
|
||||
<ProjectConfigNavigation activeId="tags" />
|
||||
</PageHeader>
|
||||
<SettingsCard
|
||||
title={t("environments.project.tags.manage_tags")}
|
||||
description={t("environments.project.tags.manage_tags_description")}>
|
||||
<div className="w-full">
|
||||
<div className="grid grid-cols-4 content-center rounded-lg bg-white text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-2">{t("environments.project.tags.tag")}</div>
|
||||
<div className="col-span-1 text-center">{t("environments.project.tags.count")}</div>
|
||||
<div className="col-span-1 flex justify-center text-center">{t("common.actions")}</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
{[...Array(3)].map((_, idx) => (
|
||||
<div key={idx} className="grid h-16 w-full grid-cols-4 content-center">
|
||||
<div className="col-span-2 h-10 animate-pulse rounded-md bg-slate-200" />
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="h-5 w-5 animate-pulse rounded-md bg-slate-200" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-1/2 animate-pulse rounded-md bg-slate-200" />
|
||||
<div className="h-8 w-1/2 animate-pulse rounded-md bg-slate-200" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import {
|
||||
getMultiLanguagePermission,
|
||||
getRoleManagementPermission,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
|
||||
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
|
||||
import { getTagsOnResponsesCount } from "@formbricks/lib/tagOnResponse/service";
|
||||
import { EditTagsWrapper } from "./components/edit-tags-wrapper";
|
||||
|
||||
export const TagsPage = async (props) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const [tags, environmentTagsCount, organization, session, project] = await Promise.all([
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
getTagsOnResponsesCount(params.environmentId),
|
||||
getOrganizationByEnvironmentId(params.environmentId),
|
||||
getServerSession(authOptions),
|
||||
getProjectByEnvironmentId(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const { isMember } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
|
||||
const { hasManageAccess } = getTeamPermissionFlags(projectPermission);
|
||||
|
||||
const isReadOnly = isMember && !hasManageAccess;
|
||||
|
||||
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
|
||||
const canDoRoleManagement = await getRoleManagementPermission(organization);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.configuration")}>
|
||||
<ProjectConfigNavigation
|
||||
environmentId={params.environmentId}
|
||||
activeId="tags"
|
||||
isMultiLanguageAllowed={isMultiLanguageAllowed}
|
||||
canDoRoleManagement={canDoRoleManagement}
|
||||
/>
|
||||
</PageHeader>
|
||||
<SettingsCard
|
||||
title={t("environments.project.tags.manage_tags")}
|
||||
description={t("environments.project.tags.manage_tags_description")}>
|
||||
<EditTagsWrapper
|
||||
environment={environment}
|
||||
environmentTags={tags}
|
||||
environmentTagsCount={environmentTagsCount}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
</SettingsCard>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user