mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
chore: Rewrite environment navbar to server components (#690)
* moved environment navbar to RSC * added Error component * format * update errors path * use standard services in environment navbar, update product service according to standards, add redirect shortcuts for teams and products * update analysis github workflow with environment variables * fix WEBAPP_URL is required --------- Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
committed by
GitHub
parent
cbc649111c
commit
1be6deec64
3
.github/workflows/nextjs-bundle-analysis.yml
vendored
3
.github/workflows/nextjs-bundle-analysis.yml
vendored
@@ -39,6 +39,9 @@ jobs:
|
||||
version: 7
|
||||
run_install: true
|
||||
|
||||
- name: create .env
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Restore next build
|
||||
uses: actions/cache@v3
|
||||
id: restore-build-cache
|
||||
|
||||
@@ -1,502 +1,46 @@
|
||||
"use client";
|
||||
export const revalidate = REVALIDATION_INTERVAL;
|
||||
|
||||
import FaveIcon from "@/app/favicon.ico";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/shared/DropdownMenu";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import CreateTeamModal from "@/components/team/CreateTeamModal";
|
||||
import {
|
||||
changeEnvironment,
|
||||
changeEnvironmentByProduct,
|
||||
changeEnvironmentByTeam,
|
||||
} from "@/lib/environments/changeEnvironments";
|
||||
import { useEnvironment } from "@/lib/environments/environments";
|
||||
import { formbricksLogout } from "@/lib/formbricks";
|
||||
import { useMemberships } from "@/lib/memberships";
|
||||
import { useTeam } from "@/lib/teams/teams";
|
||||
import { capitalizeFirstLetter, truncate } from "@/lib/utils";
|
||||
import formbricks from "@formbricks/js";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import {
|
||||
CustomersIcon,
|
||||
DashboardIcon,
|
||||
ErrorComponent,
|
||||
FilterIcon,
|
||||
FormIcon,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
ProfileAvatar,
|
||||
SettingsIcon,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@formbricks/ui";
|
||||
import {
|
||||
AdjustmentsVerticalIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
ChatBubbleBottomCenterTextIcon,
|
||||
ChevronDownIcon,
|
||||
CodeBracketIcon,
|
||||
CreditCardIcon,
|
||||
DocumentCheckIcon,
|
||||
HeartIcon,
|
||||
PaintBrushIcon,
|
||||
PlusIcon,
|
||||
UserCircleIcon,
|
||||
UsersIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import clsx from "clsx";
|
||||
import { MenuIcon } from "lucide-react";
|
||||
import Navigation from "@/app/(app)/environments/[environmentId]/Navigation";
|
||||
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment, getEnvironments } from "@formbricks/lib/services/environment";
|
||||
import { getProducts } from "@formbricks/lib/services/product";
|
||||
import { getTeamByEnvironmentId, getTeamsByUserId } from "@formbricks/lib/services/team";
|
||||
import { ErrorComponent } from "@formbricks/ui";
|
||||
import type { Session } from "next-auth";
|
||||
import { signOut } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import AddProductModal from "./AddProductModal";
|
||||
|
||||
interface EnvironmentsNavbarProps {
|
||||
environmentId: string;
|
||||
session: Session;
|
||||
}
|
||||
|
||||
export default function EnvironmentsNavbar({ environmentId, session }: EnvironmentsNavbarProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
export default async function EnvironmentsNavbar({ environmentId, session }: EnvironmentsNavbarProps) {
|
||||
const [environment, teams, team] = await Promise.all([
|
||||
getEnvironment(environmentId),
|
||||
getTeamsByUserId(session.user.id),
|
||||
getTeamByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
const { environment, isErrorEnvironment, isLoadingEnvironment } = useEnvironment(environmentId);
|
||||
const { memberships, isErrorMemberships, isLoadingMemberships } = useMemberships();
|
||||
const { team } = useTeam(environmentId);
|
||||
|
||||
const [currentTeamName, setCurrentTeamName] = useState("");
|
||||
const [currentTeamId, setCurrentTeamId] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false);
|
||||
const [showAddProductModal, setShowAddProductModal] = useState(false);
|
||||
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
|
||||
|
||||
const [mobileNavMenuOpen, setMobileNavMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (environment && environment.widgetSetupCompleted) {
|
||||
setWidgetSetupCompleted(true);
|
||||
} else {
|
||||
setWidgetSetupCompleted(false);
|
||||
}
|
||||
}, [environment]);
|
||||
|
||||
useEffect(() => {
|
||||
if (team && team.name !== "") {
|
||||
setCurrentTeamName(team.name);
|
||||
setCurrentTeamId(team.id);
|
||||
}
|
||||
}, [team]);
|
||||
|
||||
const navigation = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: "Surveys",
|
||||
href: `/environments/${environmentId}/surveys`,
|
||||
icon: FormIcon,
|
||||
current: pathname?.includes("/surveys"),
|
||||
},
|
||||
{
|
||||
name: "People",
|
||||
href: `/environments/${environmentId}/people`,
|
||||
icon: CustomersIcon,
|
||||
current: pathname?.includes("/people"),
|
||||
},
|
||||
{
|
||||
name: "Actions & Attributes",
|
||||
href: `/environments/${environmentId}/actions`,
|
||||
icon: FilterIcon,
|
||||
current: pathname?.includes("/actions") || pathname?.includes("/attributes"),
|
||||
},
|
||||
{
|
||||
name: "Integrations",
|
||||
href: `/environments/${environmentId}/integrations`,
|
||||
icon: DashboardIcon,
|
||||
current: pathname?.includes("/integrations"),
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
href: `/environments/${environmentId}/settings/profile`,
|
||||
icon: SettingsIcon,
|
||||
current: pathname?.includes("/settings"),
|
||||
},
|
||||
],
|
||||
[environmentId, pathname]
|
||||
);
|
||||
|
||||
const dropdownnavigation = [
|
||||
{
|
||||
title: "Survey",
|
||||
links: [
|
||||
{
|
||||
icon: AdjustmentsVerticalIcon,
|
||||
label: "Product Settings",
|
||||
href: `/environments/${environmentId}/settings/product`,
|
||||
},
|
||||
{
|
||||
icon: PaintBrushIcon,
|
||||
label: "Look & Feel",
|
||||
href: `/environments/${environmentId}/settings/lookandfeel`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Account",
|
||||
links: [
|
||||
{
|
||||
icon: UserCircleIcon,
|
||||
label: "Profile",
|
||||
href: `/environments/${environmentId}/settings/profile`,
|
||||
},
|
||||
{ icon: UsersIcon, label: "Team", href: `/environments/${environmentId}/settings/members` },
|
||||
{
|
||||
icon: CreditCardIcon,
|
||||
label: "Billing & Plan",
|
||||
href: `/environments/${environmentId}/settings/billing`,
|
||||
hidden: !IS_FORMBRICKS_CLOUD,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Setup",
|
||||
links: [
|
||||
{
|
||||
icon: DocumentCheckIcon,
|
||||
label: "Setup checklist",
|
||||
href: `/environments/${environmentId}/settings/setup`,
|
||||
hidden: widgetSetupCompleted,
|
||||
},
|
||||
{
|
||||
icon: CodeBracketIcon,
|
||||
label: "Developer Docs",
|
||||
href: "https://formbricks.com/docs",
|
||||
target: "_blank",
|
||||
},
|
||||
{
|
||||
icon: HeartIcon,
|
||||
label: "Contribute to Formbricks",
|
||||
href: "https://github.com/formbricks/formbricks",
|
||||
target: "_blank",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const handleEnvironmentChange = (environmentType: "production" | "development") => {
|
||||
changeEnvironment(environmentType, environment, router);
|
||||
};
|
||||
|
||||
const handleEnvironmentChangeByProduct = (productId: string) => {
|
||||
changeEnvironmentByProduct(productId, environment, router);
|
||||
};
|
||||
|
||||
const handleEnvironmentChangeByTeam = (teamId: string) => {
|
||||
changeEnvironmentByTeam(teamId, memberships, router);
|
||||
};
|
||||
|
||||
if (isLoadingEnvironment || loading || isLoadingMemberships) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (isErrorEnvironment || isErrorMemberships || !environment || !memberships) {
|
||||
if (!team || !environment) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
|
||||
if (pathname?.includes("/edit")) return null;
|
||||
const [products, environments] = await Promise.all([
|
||||
getProducts(team.id),
|
||||
getEnvironments(environment.productId),
|
||||
]);
|
||||
|
||||
if (!products || !environments || !teams) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="top-0 z-10 w-full border-b border-slate-200 bg-white">
|
||||
{environment?.type === "development" && (
|
||||
<div className="h-6 w-full bg-[#A33700] p-0.5 text-center text-sm text-white">
|
||||
You're in development mode. Use it to test surveys, actions and attributes.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full px-4 sm:px-6">
|
||||
<div className="flex h-14 justify-between">
|
||||
<div className="flex space-x-4 py-2">
|
||||
<Link
|
||||
href={`/environments/${environmentId}/surveys/`}
|
||||
className="flex items-center justify-center rounded-md bg-gradient-to-b text-white transition-all ease-in-out hover:scale-105">
|
||||
<Image src={FaveIcon} width={30} height={30} alt="faveicon" />
|
||||
</Link>
|
||||
|
||||
{navigation.map((item) => {
|
||||
const IconComponent: React.ElementType = item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={clsx(
|
||||
item.current
|
||||
? "bg-slate-100 text-slate-900"
|
||||
: "text-slate-900 hover:bg-slate-50 hover:text-slate-900",
|
||||
"hidden items-center rounded-md px-2 py-1 text-sm font-medium lg:inline-flex"
|
||||
)}
|
||||
aria-current={item.current ? "page" : undefined}>
|
||||
<IconComponent className="mr-3 h-5 w-5" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<div className="flex items-center lg:hidden">
|
||||
<Popover open={mobileNavMenuOpen} onOpenChange={setMobileNavMenuOpen}>
|
||||
<PopoverTrigger onClick={() => setMobileNavMenuOpen(!mobileNavMenuOpen)}>
|
||||
<span>
|
||||
<MenuIcon className="h-6 w-6 rounded-md bg-slate-200 p-1 text-slate-600" />
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="mr-4 bg-slate-100 shadow">
|
||||
<div className="flex flex-col">
|
||||
{navigation.map((navItem) => (
|
||||
<Link key={navItem.name} href={navItem.href}>
|
||||
<div
|
||||
onClick={() => setMobileNavMenuOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center space-x-2 rounded-md p-2",
|
||||
navItem.current && "bg-slate-200"
|
||||
)}>
|
||||
<navItem.icon className="h-5 w-5" />
|
||||
<span className="font-medium text-slate-600">{navItem.name}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* User Dropdown */}
|
||||
<div className="hidden lg:ml-6 lg:flex lg:items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="flex cursor-pointer flex-row items-center space-x-5">
|
||||
{session.user.image ? (
|
||||
<Image
|
||||
src={session.user.image}
|
||||
width="100"
|
||||
height="100"
|
||||
className="ph-no-capture h-9 w-9 rounded-full"
|
||||
alt="Profile picture"
|
||||
/>
|
||||
) : (
|
||||
<ProfileAvatar userId={session.user.id} />
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="ph-no-capture ph-no-capture -mb-0.5 text-sm font-bold text-slate-700">
|
||||
{truncate(environment?.product?.name, 30)}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">{capitalizeFirstLetter(team?.name)}</p>
|
||||
</div>
|
||||
<ChevronDownIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56">
|
||||
<DropdownMenuLabel className="cursor-default break-all">
|
||||
<span className="ph-no-capture font-normal">Signed in as </span>
|
||||
{session?.user?.name.length > 30 ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>{truncate(session?.user?.name, 30)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[45rem] break-all" side="left" sideOffset={5}>
|
||||
{session?.user?.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
session?.user?.name
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Product Switch */}
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<p className="">{truncate(environment?.product?.name, 20)}</p>
|
||||
{!widgetSetupCompleted && (
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="mt-0.5 h-2 w-2 rounded-full bg-amber-500 hover:bg-amber-600"></div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Your app is not connected to Formbricks.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
<p className=" block text-xs text-slate-500">Product</p>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent className="max-w-[45rem]">
|
||||
<DropdownMenuRadioGroup
|
||||
value={environment?.product.id}
|
||||
onValueChange={(v) => handleEnvironmentChangeByProduct(v)}>
|
||||
{environment?.availableProducts?.map((product) => (
|
||||
<DropdownMenuRadioItem
|
||||
value={product.id}
|
||||
className="cursor-pointer break-all"
|
||||
key={product.id}>
|
||||
{product?.name}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setShowAddProductModal(true)}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
<span>Add product</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Team Switch */}
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<div>
|
||||
<p>{currentTeamName}</p>
|
||||
<p className="block text-xs text-slate-500">Team</p>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup
|
||||
value={currentTeamId}
|
||||
onValueChange={(teamId) => handleEnvironmentChangeByTeam(teamId)}>
|
||||
{memberships?.map((membership) => (
|
||||
<DropdownMenuRadioItem
|
||||
value={membership.teamId}
|
||||
className="cursor-pointer"
|
||||
key={membership.teamId}>
|
||||
{membership?.team?.name}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setShowCreateTeamModal(true)}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
<span>Create team</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Environment Switch */}
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<div>
|
||||
<p>{capitalizeFirstLetter(environment?.type)}</p>
|
||||
<p className=" block text-xs text-slate-500">Environment</p>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup
|
||||
value={environment?.type}
|
||||
onValueChange={(v) => handleEnvironmentChange(v as "production" | "development")}>
|
||||
<DropdownMenuRadioItem value="production" className="cursor-pointer">
|
||||
Production
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="development" className="cursor-pointer">
|
||||
Development
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{dropdownnavigation.map((item) => (
|
||||
<DropdownMenuGroup key={item.title}>
|
||||
<DropdownMenuSeparator />
|
||||
{item.links.map(
|
||||
(link) =>
|
||||
!link.hidden && (
|
||||
<Link href={link.href} target={link.target} key={link.label}>
|
||||
<DropdownMenuItem key={link.label}>
|
||||
<div className="flex items-center">
|
||||
<link.icon className="mr-2 h-4 w-4" />
|
||||
<span>{link.label}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
{IS_FORMBRICKS_CLOUD && (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
onClick={() => {
|
||||
formbricks.track("Top Menu: Product Feedback");
|
||||
}}>
|
||||
<div className="flex items-center">
|
||||
<ChatBubbleBottomCenterTextIcon className="mr-2 h-4 w-4" />
|
||||
<span>Product Feedback</span>
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
await signOut();
|
||||
await formbricksLogout();
|
||||
}}>
|
||||
<div className="flex h-full w-full items-center">
|
||||
<ArrowRightOnRectangleIcon className="mr-2 h-4 w-4" />
|
||||
Logout
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AddProductModal
|
||||
open={showAddProductModal}
|
||||
setOpen={(val) => setShowAddProductModal(val)}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
<CreateTeamModal open={showCreateTeamModal} setOpen={(val) => setShowCreateTeamModal(val)} />
|
||||
</nav>
|
||||
<Navigation
|
||||
environment={environment}
|
||||
team={team}
|
||||
teams={teams}
|
||||
products={products}
|
||||
environments={environments}
|
||||
session={session}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export default function NavbarLoading() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between space-x-4 px-4 py-2">
|
||||
<div className="flex">
|
||||
<div className=" mx-2 h-8 w-8 animate-pulse rounded-md bg-gray-200"></div>
|
||||
<div className=" mx-2 h-8 w-20 animate-pulse rounded-md bg-gray-200"></div>
|
||||
<div className=" mx-2 h-8 w-20 animate-pulse rounded-md bg-gray-200"></div>
|
||||
<div className=" mx-2 h-8 w-20 animate-pulse rounded-md bg-gray-200"></div>
|
||||
<div className=" mx-2 h-8 w-20 animate-pulse rounded-md bg-gray-200"></div>
|
||||
<div className=" mx-2 h-8 w-20 animate-pulse rounded-md bg-gray-200"></div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className=" mx-2 h-8 w-8 animate-pulse rounded-full bg-gray-200"></div>
|
||||
<div className=" mx-2 h-8 w-20 animate-pulse rounded-md bg-gray-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
495
apps/web/app/(app)/environments/[environmentId]/Navigation.tsx
Normal file
495
apps/web/app/(app)/environments/[environmentId]/Navigation.tsx
Normal file
@@ -0,0 +1,495 @@
|
||||
"use client";
|
||||
|
||||
import FaveIcon from "@/app/favicon.ico";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/shared/DropdownMenu";
|
||||
import CreateTeamModal from "@/components/team/CreateTeamModal";
|
||||
import { formbricksLogout } from "@/lib/formbricks";
|
||||
import { capitalizeFirstLetter, truncate } from "@/lib/utils";
|
||||
import formbricks from "@formbricks/js";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import { TProduct } from "@formbricks/types/v1/product";
|
||||
import { TTeam } from "@formbricks/types/v1/teams";
|
||||
import {
|
||||
CustomersIcon,
|
||||
DashboardIcon,
|
||||
FilterIcon,
|
||||
FormIcon,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
ProfileAvatar,
|
||||
SettingsIcon,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@formbricks/ui";
|
||||
import {
|
||||
AdjustmentsVerticalIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
ChatBubbleBottomCenterTextIcon,
|
||||
ChevronDownIcon,
|
||||
CodeBracketIcon,
|
||||
CreditCardIcon,
|
||||
DocumentCheckIcon,
|
||||
HeartIcon,
|
||||
PaintBrushIcon,
|
||||
PlusIcon,
|
||||
UserCircleIcon,
|
||||
UsersIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import clsx from "clsx";
|
||||
import { MenuIcon } from "lucide-react";
|
||||
import type { Session } from "next-auth";
|
||||
import { signOut } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import AddProductModal from "./AddProductModal";
|
||||
|
||||
interface NavigationProps {
|
||||
environment: TEnvironment;
|
||||
teams: TTeam[];
|
||||
session: Session;
|
||||
team: TTeam;
|
||||
products: TProduct[];
|
||||
environments: TEnvironment[];
|
||||
}
|
||||
|
||||
export default function Navigation({
|
||||
environment,
|
||||
teams,
|
||||
team,
|
||||
session,
|
||||
products,
|
||||
environments,
|
||||
}: NavigationProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [currentTeamName, setCurrentTeamName] = useState("");
|
||||
const [currentTeamId, setCurrentTeamId] = useState("");
|
||||
const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false);
|
||||
const [showAddProductModal, setShowAddProductModal] = useState(false);
|
||||
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
|
||||
const product = products.find((product) => product.id === environment.productId);
|
||||
const [mobileNavMenuOpen, setMobileNavMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (environment && environment.widgetSetupCompleted) {
|
||||
setWidgetSetupCompleted(true);
|
||||
} else {
|
||||
setWidgetSetupCompleted(false);
|
||||
}
|
||||
}, [environment]);
|
||||
|
||||
useEffect(() => {
|
||||
if (team && team.name !== "") {
|
||||
setCurrentTeamName(team.name);
|
||||
setCurrentTeamId(team.id);
|
||||
}
|
||||
}, [team]);
|
||||
|
||||
const navigation = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: "Surveys",
|
||||
href: `/environments/${environment.id}/surveys`,
|
||||
icon: FormIcon,
|
||||
current: pathname?.includes("/surveys"),
|
||||
},
|
||||
{
|
||||
name: "People",
|
||||
href: `/environments/${environment.id}/people`,
|
||||
icon: CustomersIcon,
|
||||
current: pathname?.includes("/people"),
|
||||
},
|
||||
{
|
||||
name: "Actions & Attributes",
|
||||
href: `/environments/${environment.id}/actions`,
|
||||
icon: FilterIcon,
|
||||
current: pathname?.includes("/actions") || pathname?.includes("/attributes"),
|
||||
},
|
||||
{
|
||||
name: "Integrations",
|
||||
href: `/environments/${environment.id}/integrations`,
|
||||
icon: DashboardIcon,
|
||||
current: pathname?.includes("/integrations"),
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
href: `/environments/${environment.id}/settings/profile`,
|
||||
icon: SettingsIcon,
|
||||
current: pathname?.includes("/settings"),
|
||||
},
|
||||
],
|
||||
[environment.id, pathname]
|
||||
);
|
||||
|
||||
const dropdownnavigation = [
|
||||
{
|
||||
title: "Survey",
|
||||
links: [
|
||||
{
|
||||
icon: AdjustmentsVerticalIcon,
|
||||
label: "Product Settings",
|
||||
href: `/environments/${environment.id}/settings/product`,
|
||||
},
|
||||
{
|
||||
icon: PaintBrushIcon,
|
||||
label: "Look & Feel",
|
||||
href: `/environments/${environment.id}/settings/lookandfeel`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Account",
|
||||
links: [
|
||||
{
|
||||
icon: UserCircleIcon,
|
||||
label: "Profile",
|
||||
href: `/environments/${environment.id}/settings/profile`,
|
||||
},
|
||||
{ icon: UsersIcon, label: "Team", href: `/environments/${environment.id}/settings/members` },
|
||||
{
|
||||
icon: CreditCardIcon,
|
||||
label: "Billing & Plan",
|
||||
href: `/environments/${environment.id}/settings/billing`,
|
||||
hidden: !IS_FORMBRICKS_CLOUD,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Setup",
|
||||
links: [
|
||||
{
|
||||
icon: DocumentCheckIcon,
|
||||
label: "Setup checklist",
|
||||
href: `/environments/${environment.id}/settings/setup`,
|
||||
hidden: widgetSetupCompleted,
|
||||
},
|
||||
{
|
||||
icon: CodeBracketIcon,
|
||||
label: "Developer Docs",
|
||||
href: "https://formbricks.com/docs",
|
||||
target: "_blank",
|
||||
},
|
||||
{
|
||||
icon: HeartIcon,
|
||||
label: "Contribute to Formbricks",
|
||||
href: "https://github.com/formbricks/formbricks",
|
||||
target: "_blank",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const handleEnvironmentChange = (environmentType: "production" | "development") => {
|
||||
const newEnvironmentId = environments.find((e) => e.type === environmentType)?.id;
|
||||
if (newEnvironmentId) {
|
||||
router.push(`/environments/${newEnvironmentId}/`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnvironmentChangeByProduct = (productId: string) => {
|
||||
router.push(`/products/${productId}/`);
|
||||
};
|
||||
|
||||
const handleEnvironmentChangeByTeam = (teamId: string) => {
|
||||
router.push(`/teams/${teamId}/`);
|
||||
};
|
||||
|
||||
if (pathname?.includes("/edit")) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{product && (
|
||||
<nav className="top-0 z-10 w-full border-b border-slate-200 bg-white">
|
||||
{environment?.type === "development" && (
|
||||
<div className="h-6 w-full bg-[#A33700] p-0.5 text-center text-sm text-white">
|
||||
You're in development mode. Use it to test surveys, actions and attributes.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full px-4 sm:px-6">
|
||||
<div className="flex h-14 justify-between">
|
||||
<div className="flex space-x-4 py-2">
|
||||
<Link
|
||||
href={`/environments/${environment.id}/surveys/`}
|
||||
className="flex items-center justify-center rounded-md bg-gradient-to-b text-white transition-all ease-in-out hover:scale-105">
|
||||
<Image src={FaveIcon} width={30} height={30} alt="faveicon" />
|
||||
</Link>
|
||||
|
||||
{navigation.map((item) => {
|
||||
const IconComponent: React.ElementType = item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={clsx(
|
||||
item.current
|
||||
? "bg-slate-100 text-slate-900"
|
||||
: "text-slate-900 hover:bg-slate-50 hover:text-slate-900",
|
||||
"hidden items-center rounded-md px-2 py-1 text-sm font-medium lg:inline-flex"
|
||||
)}
|
||||
aria-current={item.current ? "page" : undefined}>
|
||||
<IconComponent className="mr-3 h-5 w-5" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<div className="flex items-center lg:hidden">
|
||||
<Popover open={mobileNavMenuOpen} onOpenChange={setMobileNavMenuOpen}>
|
||||
<PopoverTrigger onClick={() => setMobileNavMenuOpen(!mobileNavMenuOpen)}>
|
||||
<span>
|
||||
<MenuIcon className="h-6 w-6 rounded-md bg-slate-200 p-1 text-slate-600" />
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="mr-4 bg-slate-100 shadow">
|
||||
<div className="flex flex-col">
|
||||
{navigation.map((navItem) => (
|
||||
<Link key={navItem.name} href={navItem.href}>
|
||||
<div
|
||||
onClick={() => setMobileNavMenuOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center space-x-2 rounded-md p-2",
|
||||
navItem.current && "bg-slate-200"
|
||||
)}>
|
||||
<navItem.icon className="h-5 w-5" />
|
||||
<span className="font-medium text-slate-600">{navItem.name}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* User Dropdown */}
|
||||
<div className="hidden lg:ml-6 lg:flex lg:items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="flex cursor-pointer flex-row items-center space-x-5">
|
||||
{session.user.image ? (
|
||||
<Image
|
||||
src={session.user.image}
|
||||
width="100"
|
||||
height="100"
|
||||
className="ph-no-capture h-9 w-9 rounded-full"
|
||||
alt="Profile picture"
|
||||
/>
|
||||
) : (
|
||||
<ProfileAvatar userId={session.user.id} />
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="ph-no-capture ph-no-capture -mb-0.5 text-sm font-bold text-slate-700">
|
||||
{truncate(product!.name, 30)}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">{capitalizeFirstLetter(team?.name)}</p>
|
||||
</div>
|
||||
<ChevronDownIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56">
|
||||
<DropdownMenuLabel className="cursor-default break-all">
|
||||
<span className="ph-no-capture font-normal">Signed in as </span>
|
||||
{session?.user?.name.length > 30 ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>{truncate(session?.user?.name, 30)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[45rem] break-all" side="left" sideOffset={5}>
|
||||
{session?.user?.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
session?.user?.name
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Product Switch */}
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<p className="">{truncate(product!.name, 20)}</p>
|
||||
{!widgetSetupCompleted && (
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="mt-0.5 h-2 w-2 rounded-full bg-amber-500 hover:bg-amber-600"></div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Your app is not connected to Formbricks.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
<p className=" block text-xs text-slate-500">Product</p>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent className="max-w-[45rem]">
|
||||
<DropdownMenuRadioGroup
|
||||
value={product!.id}
|
||||
onValueChange={(v) => handleEnvironmentChangeByProduct(v)}>
|
||||
{products.map((product) => (
|
||||
<DropdownMenuRadioItem
|
||||
value={product.id}
|
||||
className="cursor-pointer break-all"
|
||||
key={product.id}>
|
||||
{product?.name}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setShowAddProductModal(true)}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
<span>Add product</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Team Switch */}
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<div>
|
||||
<p>{currentTeamName}</p>
|
||||
<p className="block text-xs text-slate-500">Team</p>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup
|
||||
value={currentTeamId}
|
||||
onValueChange={(teamId) => handleEnvironmentChangeByTeam(teamId)}>
|
||||
{teams?.map((team) => (
|
||||
<DropdownMenuRadioItem value={team.id} className="cursor-pointer" key={team.id}>
|
||||
{team.name}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setShowCreateTeamModal(true)}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
<span>Create team</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Environment Switch */}
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<div>
|
||||
<p>{capitalizeFirstLetter(environment?.type)}</p>
|
||||
<p className=" block text-xs text-slate-500">Environment</p>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup
|
||||
value={environment?.type}
|
||||
onValueChange={(v) => handleEnvironmentChange(v as "production" | "development")}>
|
||||
<DropdownMenuRadioItem value="production" className="cursor-pointer">
|
||||
Production
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="development" className="cursor-pointer">
|
||||
Development
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{dropdownnavigation.map((item) => (
|
||||
<DropdownMenuGroup key={item.title}>
|
||||
<DropdownMenuSeparator />
|
||||
{item.links.map(
|
||||
(link) =>
|
||||
!link.hidden && (
|
||||
<Link href={link.href} target={link.target} key={link.label}>
|
||||
<DropdownMenuItem key={link.label}>
|
||||
<div className="flex items-center">
|
||||
<link.icon className="mr-2 h-4 w-4" />
|
||||
<span>{link.label}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
{IS_FORMBRICKS_CLOUD && (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
onClick={() => {
|
||||
formbricks.track("Top Menu: Product Feedback");
|
||||
}}>
|
||||
<div className="flex items-center">
|
||||
<ChatBubbleBottomCenterTextIcon className="mr-2 h-4 w-4" />
|
||||
<span>Product Feedback</span>
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await signOut();
|
||||
await formbricksLogout();
|
||||
}}>
|
||||
<div className="flex h-full w-full items-center">
|
||||
<ArrowRightOnRectangleIcon className="mr-2 h-4 w-4" />
|
||||
Logout
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AddProductModal
|
||||
open={showAddProductModal}
|
||||
setOpen={(val) => setShowAddProductModal(val)}
|
||||
environmentId={environment.id}
|
||||
/>
|
||||
<CreateTeamModal open={showCreateTeamModal} setOpen={(val) => setShowCreateTeamModal(val)} />
|
||||
</nav>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,10 @@ export default async function ApiKeyList({
|
||||
};
|
||||
|
||||
const product = await getProductByEnvironmentId(environmentId);
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
const environments = await getEnvironments(product.id);
|
||||
const environmentTypeId = findEnvironmentByType(environments, environmentType);
|
||||
const apiKeys = await getApiKeys(environmentTypeId);
|
||||
|
||||
@@ -11,6 +11,9 @@ import { EditHighlightBorder } from "./EditHighlightBorder";
|
||||
|
||||
export default async function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
|
||||
const product = await getProductByEnvironmentId(params.environmentId);
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<SettingsTitle title="Look & Feel" />
|
||||
|
||||
@@ -14,6 +14,10 @@ import { SURVEY_BASE_URL } from "@formbricks/lib/constants";
|
||||
|
||||
export default async function SurveysList({ environmentId }: { environmentId: string }) {
|
||||
const product = await getProductByEnvironmentId(environmentId);
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
const environment = await getEnvironment(environmentId);
|
||||
const surveys: TSurveyWithAnalytics[] = await getSurveysWithAnalytics(environmentId);
|
||||
const environments: TEnvironment[] = await getEnvironments(product.id);
|
||||
|
||||
@@ -7,6 +7,10 @@ export default async function SurveyTemplatesPage({ params }) {
|
||||
const environment = await getEnvironment(environmentId);
|
||||
const product = await getProductByEnvironmentId(environmentId);
|
||||
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
return (
|
||||
<TemplateContainerWithPreview environmentId={environmentId} environment={environment} product={product} />
|
||||
);
|
||||
|
||||
24
apps/web/app/(redirects)/products/[productId]/route.ts
Normal file
24
apps/web/app/(redirects)/products/[productId]/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { hasTeamAccess } from "@/lib/api/apiHelper";
|
||||
import { getEnvironments } from "@formbricks/lib/services/environment";
|
||||
import { getProduct } from "@formbricks/lib/services/product";
|
||||
import { AuthenticationError, AuthorizationError } from "@formbricks/types/v1/errors";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
export async function GET(_: Request, context: { params: { productId: string } }) {
|
||||
const productId = context?.params?.productId;
|
||||
if (!productId) return notFound();
|
||||
// check auth
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthenticationError("Not authenticated");
|
||||
const product = await getProduct(productId);
|
||||
if (!product) return notFound();
|
||||
const hasAccess = await hasTeamAccess(session.user, product.teamId);
|
||||
if (!hasAccess) throw new AuthorizationError("Unauthorized");
|
||||
// redirect to product's production environment
|
||||
const environments = await getEnvironments(product.id);
|
||||
const prodEnvironment = environments.find((e) => e.type === "production");
|
||||
if (!prodEnvironment) return notFound();
|
||||
redirect(`/environments/${prodEnvironment.id}/`);
|
||||
}
|
||||
26
apps/web/app/(redirects)/teams/[teamId]/route.ts
Normal file
26
apps/web/app/(redirects)/teams/[teamId]/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { hasTeamAccess } from "@/lib/api/apiHelper";
|
||||
import { getEnvironments } from "@formbricks/lib/services/environment";
|
||||
import { getProducts } from "@formbricks/lib/services/product";
|
||||
import { AuthenticationError, AuthorizationError } from "@formbricks/types/v1/errors";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export async function GET(_: Request, context: { params: { teamId: string } }) {
|
||||
const teamId = context?.params?.teamId;
|
||||
if (!teamId) return notFound();
|
||||
// check auth
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthenticationError("Not authenticated");
|
||||
const hasAccess = await hasTeamAccess(session.user, teamId);
|
||||
if (!hasAccess) throw new AuthorizationError("Unauthorized");
|
||||
// redirect to first product's production environment
|
||||
const products = await getProducts(teamId);
|
||||
if (products.length === 0) return notFound();
|
||||
const firstProduct = products[0];
|
||||
const environments = await getEnvironments(firstProduct.id);
|
||||
const prodEnvironment = environments.find((e) => e.type === "production");
|
||||
if (!prodEnvironment) return notFound();
|
||||
redirect(`/environments/${prodEnvironment.id}/`);
|
||||
}
|
||||
@@ -109,6 +109,10 @@ export async function POST(req: Request, { params }): Promise<NextResponse> {
|
||||
getProductByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
if (!product) {
|
||||
return responses.notFoundResponse("ProductByEnvironmentId", environmentId, true);
|
||||
}
|
||||
|
||||
// return state
|
||||
const state: TJsState = {
|
||||
person,
|
||||
|
||||
@@ -101,6 +101,10 @@ export async function POST(req: Request, { params }): Promise<NextResponse> {
|
||||
getProductByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
if (!product) {
|
||||
return responses.notFoundResponse("ProductByEnvironmentId", environmentId, true);
|
||||
}
|
||||
|
||||
// return state
|
||||
const state: TJsState = {
|
||||
person,
|
||||
|
||||
@@ -60,6 +60,10 @@ export async function POST(req: Request): Promise<NextResponse> {
|
||||
|
||||
captureNewSessionTelemetry(inputValidation.data.jsVersion);
|
||||
|
||||
if (!product) {
|
||||
return responses.notFoundResponse("ProductByEnvironmentId", environmentId, true);
|
||||
}
|
||||
|
||||
// return state
|
||||
const state: TJsState = {
|
||||
person,
|
||||
@@ -87,6 +91,10 @@ export async function POST(req: Request): Promise<NextResponse> {
|
||||
getProductByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
if (!product) {
|
||||
return responses.notFoundResponse("ProductByEnvironmentId", environmentId, true);
|
||||
}
|
||||
|
||||
captureNewSessionTelemetry(inputValidation.data.jsVersion);
|
||||
|
||||
// return state
|
||||
@@ -144,6 +152,10 @@ export async function POST(req: Request): Promise<NextResponse> {
|
||||
getProductByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
if (!product) {
|
||||
return responses.notFoundResponse("ProductByEnvironmentId", environmentId, true);
|
||||
}
|
||||
|
||||
// return state
|
||||
const state: TJsState = {
|
||||
person,
|
||||
|
||||
@@ -36,6 +36,10 @@ export default async function LinkSurveyPage({ params, searchParams }) {
|
||||
|
||||
// get product and person
|
||||
const product = await getProductByEnvironmentId(survey.environmentId);
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
const userId = searchParams.userId;
|
||||
let person;
|
||||
if (userId) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createTeam } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import Modal from "@/components/shared/Modal";
|
||||
import { changeEnvironmentByTeam } from "@/lib/environments/changeEnvironments";
|
||||
import { useMemberships } from "@/lib/memberships";
|
||||
import { useProfile } from "@/lib/profile";
|
||||
import { Button, Input, Label } from "@formbricks/ui";
|
||||
import { PlusCircleIcon } from "@heroicons/react/24/outline";
|
||||
@@ -8,7 +7,6 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { createTeam } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
|
||||
interface CreateTeamModalProps {
|
||||
open: boolean;
|
||||
@@ -18,7 +16,6 @@ interface CreateTeamModalProps {
|
||||
export default function CreateTeamModal({ open, setOpen }: CreateTeamModalProps) {
|
||||
const router = useRouter();
|
||||
const { profile } = useProfile();
|
||||
const { mutateMemberships } = useMemberships();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { register, handleSubmit } = useForm();
|
||||
|
||||
@@ -26,9 +23,8 @@ export default function CreateTeamModal({ open, setOpen }: CreateTeamModalProps)
|
||||
setLoading(true);
|
||||
const newTeam = await createTeam(data.name, (profile as any).id);
|
||||
|
||||
const newMemberships = await mutateMemberships();
|
||||
changeEnvironmentByTeam(newTeam.id, newMemberships, router);
|
||||
toast.success("Team created successfully!");
|
||||
router.push(`/teams/${newTeam.id}`);
|
||||
setOpen(false);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ export const env = createEnv({
|
||||
* Will throw if you access these variables on the client.
|
||||
*/
|
||||
server: {
|
||||
WEBAPP_URL: z.string().url(),
|
||||
WEBAPP_URL: z.string().url().optional(),
|
||||
DATABASE_URL: z.string().url(),
|
||||
PRISMA_GENERATE_DATAPROXY: z.enum(["true", ""]).optional(),
|
||||
NEXTAUTH_SECRET: z.string().min(1),
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
export const changeEnvironment = (environmentType: string, environment: any, router: any) => {
|
||||
const newEnvironmentId = environment.product.environments.find((e) => e.type === environmentType)?.id;
|
||||
if (newEnvironmentId) {
|
||||
router.push(`/environments/${newEnvironmentId}/`);
|
||||
}
|
||||
};
|
||||
|
||||
export const changeEnvironmentByProduct = (productId: string, environment: any, router: any) => {
|
||||
const product = environment.availableProducts.find((p) => p.id === productId);
|
||||
const newEnvironmentId = product?.environments[0]?.id;
|
||||
if (newEnvironmentId) {
|
||||
router.push(`/environments/${newEnvironmentId}/`);
|
||||
}
|
||||
};
|
||||
|
||||
export const changeEnvironmentByTeam = (teamId: string, memberships: any, router: any) => {
|
||||
const newTeamMembership = memberships.find((m) => m.teamId === teamId);
|
||||
const newTeamProduct = newTeamMembership?.team?.products?.[0];
|
||||
|
||||
if (newTeamProduct) {
|
||||
const newEnvironmentId = newTeamProduct.environments.find((e) => e.type === "production")?.id;
|
||||
|
||||
if (newEnvironmentId) {
|
||||
router.push(`/environments/${newEnvironmentId}/`);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
import "server-only";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/v1/errors";
|
||||
import { ZProduct } from "@formbricks/types/v1/product";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/v1/errors";
|
||||
import type { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
|
||||
import { ZProduct } from "@formbricks/types/v1/product";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache } from "react";
|
||||
import "server-only";
|
||||
import { z } from "zod";
|
||||
|
||||
const selectProduct = {
|
||||
id: true,
|
||||
@@ -22,7 +22,26 @@ const selectProduct = {
|
||||
darkOverlay: true,
|
||||
};
|
||||
|
||||
export const getProductByEnvironmentId = cache(async (environmentId: string): Promise<TProduct> => {
|
||||
export const getProducts = cache(async (teamId: string): Promise<TProduct[]> => {
|
||||
try {
|
||||
const products = await prisma.product.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
select: selectProduct,
|
||||
});
|
||||
|
||||
return products;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
export const getProductByEnvironmentId = cache(async (environmentId: string): Promise<TProduct | null> => {
|
||||
if (!environmentId) {
|
||||
throw new ValidationError("EnvironmentId is required");
|
||||
}
|
||||
@@ -39,25 +58,13 @@ export const getProductByEnvironmentId = cache(async (environmentId: string): Pr
|
||||
select: selectProduct,
|
||||
});
|
||||
|
||||
if (!productPrisma) {
|
||||
throw new ResourceNotFoundError("Product for Environment", environmentId);
|
||||
}
|
||||
return productPrisma;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const product = ZProduct.parse(productPrisma);
|
||||
return product;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
console.error(JSON.stringify(error.errors, null, 2));
|
||||
}
|
||||
throw new ValidationError("Data validation of product failed");
|
||||
}
|
||||
});
|
||||
|
||||
export const updateProduct = async (
|
||||
@@ -91,3 +98,22 @@ export const updateProduct = async (
|
||||
throw new ValidationError("Data validation of product failed");
|
||||
}
|
||||
};
|
||||
|
||||
export const getProduct = cache(async (productId: string): Promise<TProduct | null> => {
|
||||
let productPrisma;
|
||||
try {
|
||||
productPrisma = await prisma.product.findUnique({
|
||||
where: {
|
||||
id: productId,
|
||||
},
|
||||
select: selectProduct,
|
||||
});
|
||||
|
||||
return productPrisma;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -32,6 +32,29 @@ export const select = {
|
||||
stripeCustomerId: true,
|
||||
};
|
||||
|
||||
export const getTeamsByUserId = cache(async (userId: string): Promise<TTeam[]> => {
|
||||
try {
|
||||
const teams = await prisma.team.findMany({
|
||||
where: {
|
||||
memberships: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select,
|
||||
});
|
||||
|
||||
return teams;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
export const getTeamByEnvironmentId = cache(async (environmentId: string): Promise<TTeam | null> => {
|
||||
try {
|
||||
const team = await prisma.team.findFirst({
|
||||
|
||||
Reference in New Issue
Block a user