mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-22 19:39:01 -05:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67161155a9 | |||
| 9b0cf5f532 | |||
| a32241d7c8 | |||
| a296ad189a | |||
| 942cb0f8d0 | |||
| 3e3b8cc349 | |||
| 63fe32a786 | |||
| 84c465f974 | |||
| 6a33498737 | |||
| 5130c747d4 | |||
| f5583d2652 | |||
| e0d75914a4 | |||
| f02ca1cfe1 | |||
| 4ade83f189 | |||
| f1fc9fea2c | |||
| 25266e4566 | |||
| b960cfd2a1 | |||
| 9e1d1c1dc2 | |||
| 8c63a9f7af | |||
| fff0a7f052 | |||
| 0ecc8aabff | |||
| 01cc0ab64d | |||
| 1d125bdac2 | |||
| ca67c4d5a8 | |||
| d167d591ce | |||
| acc3b0179a | |||
| 3434b5cf08 | |||
| a618f2df95 | |||
| 5b334f6623 | |||
| fa2b63d6a1 | |||
| 9f0fe69b6b | |||
| 98cb2de02b | |||
| f00d0b7e20 | |||
| 65abd4ee07 | |||
| 939f135bf4 | |||
| 729a16854a | |||
| a2d3e37d69 | |||
| adf12f551d | |||
| 3f2bddc358 | |||
| ae6d1ac133 | |||
| 7c4569cd50 | |||
| 7354122447 | |||
| d54dca2b27 | |||
| acd5cff534 | |||
| 834929e766 | |||
| 09f40ad816 | |||
| 689b6491b3 | |||
| b70b2eef95 | |||
| 392a95834b | |||
| 66d9cc8eac | |||
| befdc078f1 | |||
| 13b983b3b2 | |||
| 1e285ebe4e | |||
| a7c4971952 | |||
| c8689d91d5 | |||
| 73a2ff7421 | |||
| 0c28e89b41 | |||
| a736436e29 | |||
| 7dbb0300d3 |
@@ -168,6 +168,9 @@ SLACK_CLIENT_SECRET=
|
|||||||
# Enterprise License Key
|
# Enterprise License Key
|
||||||
ENTERPRISE_LICENSE_KEY=
|
ENTERPRISE_LICENSE_KEY=
|
||||||
|
|
||||||
|
# Internal Environment (production, staging) - used for internal staging environment
|
||||||
|
# ENVIRONMENT=production
|
||||||
|
|
||||||
# Automatically assign new users to a specific organization and role within that organization
|
# Automatically assign new users to a specific organization and role within that organization
|
||||||
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
|
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
|
||||||
# (Role Management is an Enterprise feature)
|
# (Role Management is an Enterprise feature)
|
||||||
|
|||||||
@@ -13,13 +13,12 @@ jobs:
|
|||||||
chromatic:
|
chromatic:
|
||||||
name: Run Chromatic
|
name: Run Chromatic
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
contents: read
|
||||||
id-token: write
|
|
||||||
actions: read
|
|
||||||
steps:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
@@ -27,16 +26,34 @@ jobs:
|
|||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
|
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Setup pnpm cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: ${{ env.STORE_PATH }}
|
||||||
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-store-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||||
|
|
||||||
- name: Run Chromatic
|
- name: Run Chromatic
|
||||||
uses: chromaui/action@c93e0bc3a63aa176e14a75b61a31847cbfdd341c # latest
|
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
|
||||||
with:
|
with:
|
||||||
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
|
|
||||||
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||||
workingDir: apps/storybook
|
workingDir: apps/storybook
|
||||||
|
zip: true
|
||||||
|
|||||||
@@ -203,6 +203,14 @@ Here are a few options:
|
|||||||
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
## Thanks
|
||||||
|
|
||||||
|
Formbricks is supported by the following companies who provide us with their tools for free as part of their open-source support:
|
||||||
|
|
||||||
|
<a href="https://www.chromatic.com/"><img src="https://user-images.githubusercontent.com/321738/84662277-e3db4f80-af1b-11ea-88f5-91d67a5e59f6.png" width="153" height="30" alt="Chromatic" /></a>
|
||||||
|
|
||||||
|
<a href="https://sentry.io/"><img src="https://github.com/user-attachments/assets/d743ffd4-b575-4802-a29a-10136be9227e" width="150" height="30" alt="Sentry" /></a>
|
||||||
|
|
||||||
<a id="contact-us"></a>
|
<a id="contact-us"></a>
|
||||||
|
|
||||||
## 📆 Contact us
|
## 📆 Contact us
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const Page = async (props: ConnectPageProps) => {
|
|||||||
|
|
||||||
const project = await getProjectByEnvironmentId(environment.id);
|
const project = await getProjectByEnvironmentId(environment.id);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error(t("common.project_not_found"));
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = project.config.channel || null;
|
const channel = project.config.channel || null;
|
||||||
@@ -46,7 +46,7 @@ const Page = async (props: ConnectPageProps) => {
|
|||||||
channel={channel}
|
channel={channel}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={`/environments/${environment.id}`}>
|
<Link href={`/environments/${environment.id}`}>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const Page = async (props: XMTemplatePageProps) => {
|
|||||||
|
|
||||||
const project = await getProjectByEnvironmentId(environment.id);
|
const project = await getProjectByEnvironmentId(environment.id);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error(t("common.project_not_found"));
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const projects = await getUserProjects(session.user.id, organizationId);
|
const projects = await getUserProjects(session.user.id, organizationId);
|
||||||
@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
|
|||||||
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
||||||
{projects.length >= 2 && (
|
{projects.length >= 2 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={`/environments/${environment.id}/surveys`}>
|
<Link href={`/environments/${environment.id}/surveys`}>
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ const Page = async (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
||||||
<Header
|
<Header
|
||||||
title={t("organizations.landing.no_projects_warning_title")}
|
title={t("organizations.landing.no_workspaces_warning_title")}
|
||||||
subtitle={t("organizations.landing.no_projects_warning_subtitle")}
|
subtitle={t("organizations.landing.no_workspaces_warning_subtitle")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+9
-9
@@ -26,16 +26,16 @@ const Page = async (props: ChannelPageProps) => {
|
|||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const channelOptions = [
|
const channelOptions = [
|
||||||
{
|
{
|
||||||
title: t("organizations.projects.new.channel.link_and_email_surveys"),
|
title: t("organizations.workspaces.new.channel.link_and_email_surveys"),
|
||||||
description: t("organizations.projects.new.channel.link_and_email_surveys_description"),
|
description: t("organizations.workspaces.new.channel.link_and_email_surveys_description"),
|
||||||
icon: SendIcon,
|
icon: SendIcon,
|
||||||
href: `/organizations/${params.organizationId}/projects/new/settings?channel=link`,
|
href: `/organizations/${params.organizationId}/workspaces/new/settings?channel=link`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("organizations.projects.new.channel.in_product_surveys"),
|
title: t("organizations.workspaces.new.channel.in_product_surveys"),
|
||||||
description: t("organizations.projects.new.channel.in_product_surveys_description"),
|
description: t("organizations.workspaces.new.channel.in_product_surveys_description"),
|
||||||
icon: PictureInPicture2Icon,
|
icon: PictureInPicture2Icon,
|
||||||
href: `/organizations/${params.organizationId}/projects/new/settings?channel=app`,
|
href: `/organizations/${params.organizationId}/workspaces/new/settings?channel=app`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -44,13 +44,13 @@ const Page = async (props: ChannelPageProps) => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||||
<Header
|
<Header
|
||||||
title={t("organizations.projects.new.channel.channel_select_title")}
|
title={t("organizations.workspaces.new.channel.channel_select_title")}
|
||||||
subtitle={t("organizations.projects.new.channel.channel_select_subtitle")}
|
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
|
||||||
/>
|
/>
|
||||||
<OnboardingOptionsContainer options={channelOptions} />
|
<OnboardingOptionsContainer options={channelOptions} />
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
+1
-1
@@ -15,7 +15,7 @@ const OnboardingLayout = async (props) => {
|
|||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
|
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session || !session.user) {
|
if (!session?.user) {
|
||||||
return redirect(`/auth/login`);
|
return redirect(`/auth/login`);
|
||||||
}
|
}
|
||||||
|
|
||||||
+8
-8
@@ -26,16 +26,16 @@ const Page = async (props: ModePageProps) => {
|
|||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const channelOptions = [
|
const channelOptions = [
|
||||||
{
|
{
|
||||||
title: t("organizations.projects.new.mode.formbricks_surveys"),
|
title: t("organizations.workspaces.new.mode.formbricks_surveys"),
|
||||||
description: t("organizations.projects.new.mode.formbricks_surveys_description"),
|
description: t("organizations.workspaces.new.mode.formbricks_surveys_description"),
|
||||||
icon: ListTodoIcon,
|
icon: ListTodoIcon,
|
||||||
href: `/organizations/${params.organizationId}/projects/new/channel`,
|
href: `/organizations/${params.organizationId}/workspaces/new/channel`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("organizations.projects.new.mode.formbricks_cx"),
|
title: t("organizations.workspaces.new.mode.formbricks_cx"),
|
||||||
description: t("organizations.projects.new.mode.formbricks_cx_description"),
|
description: t("organizations.workspaces.new.mode.formbricks_cx_description"),
|
||||||
icon: HeartIcon,
|
icon: HeartIcon,
|
||||||
href: `/organizations/${params.organizationId}/projects/new/settings?mode=cx`,
|
href: `/organizations/${params.organizationId}/workspaces/new/settings?mode=cx`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -43,11 +43,11 @@ const Page = async (props: ModePageProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||||
<Header title={t("organizations.projects.new.mode.what_are_you_here_for")} />
|
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
|
||||||
<OnboardingOptionsContainer options={channelOptions} />
|
<OnboardingOptionsContainer options={channelOptions} />
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
+9
-10
@@ -79,7 +79,7 @@ export const ProjectSettings = ({
|
|||||||
(environment) => environment.type === "production"
|
(environment) => environment.type === "production"
|
||||||
);
|
);
|
||||||
if (productionEnvironment) {
|
if (productionEnvironment) {
|
||||||
if (typeof window !== "undefined") {
|
if (globalThis.window !== undefined) {
|
||||||
// Rmove filters when creating a new project
|
// Rmove filters when creating a new project
|
||||||
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@ export const ProjectSettings = ({
|
|||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t("organizations.projects.new.settings.project_creation_failed"));
|
toast.error(t("organizations.workspaces.new.settings.workspace_creation_failed"));
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -107,7 +107,6 @@ export const ProjectSettings = ({
|
|||||||
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
|
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
|
||||||
teamIds: [],
|
teamIds: [],
|
||||||
},
|
},
|
||||||
|
|
||||||
resolver: zodResolver(ZProjectUpdateInput),
|
resolver: zodResolver(ZProjectUpdateInput),
|
||||||
});
|
});
|
||||||
const projectName = form.watch("name");
|
const projectName = form.watch("name");
|
||||||
@@ -131,9 +130,9 @@ export const ProjectSettings = ({
|
|||||||
render={({ field, fieldState: { error } }) => (
|
render={({ field, fieldState: { error } }) => (
|
||||||
<FormItem className="w-full space-y-4">
|
<FormItem className="w-full space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<FormLabel>{t("organizations.projects.new.settings.brand_color")}</FormLabel>
|
<FormLabel>{t("organizations.workspaces.new.settings.brand_color")}</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("organizations.projects.new.settings.brand_color_description")}
|
{t("organizations.workspaces.new.settings.brand_color_description")}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -155,9 +154,9 @@ export const ProjectSettings = ({
|
|||||||
render={({ field, fieldState: { error } }) => (
|
render={({ field, fieldState: { error } }) => (
|
||||||
<FormItem className="w-full space-y-4">
|
<FormItem className="w-full space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<FormLabel>{t("organizations.projects.new.settings.project_name")}</FormLabel>
|
<FormLabel>{t("organizations.workspaces.new.settings.workspace_name")}</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("organizations.projects.new.settings.project_name_description")}
|
{t("organizations.workspaces.new.settings.workspace_name_description")}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -186,7 +185,7 @@ export const ProjectSettings = ({
|
|||||||
<div>
|
<div>
|
||||||
<FormLabel>{t("common.teams")}</FormLabel>
|
<FormLabel>{t("common.teams")}</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("organizations.projects.new.settings.team_description")}
|
{t("organizations.workspaces.new.settings.team_description")}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -194,7 +193,7 @@ export const ProjectSettings = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCreateTeamModalOpen(true)}>
|
onClick={() => setCreateTeamModalOpen(true)}>
|
||||||
{t("organizations.projects.new.settings.create_new_team")}
|
{t("organizations.workspaces.new.settings.create_new_team")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -227,7 +226,7 @@ export const ProjectSettings = ({
|
|||||||
alt="Logo"
|
alt="Logo"
|
||||||
width={256}
|
width={256}
|
||||||
height={56}
|
height={56}
|
||||||
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
||||||
+4
-4
@@ -3,7 +3,7 @@ import Link from "next/link";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
||||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
|
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
|
||||||
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
@@ -53,8 +53,8 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||||
<Header
|
<Header
|
||||||
title={t("organizations.projects.new.settings.project_settings_title")}
|
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
|
||||||
subtitle={t("organizations.projects.new.settings.project_settings_subtitle")}
|
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
|
||||||
/>
|
/>
|
||||||
<ProjectSettings
|
<ProjectSettings
|
||||||
organizationId={params.organizationId}
|
organizationId={params.organizationId}
|
||||||
@@ -69,7 +69,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
/>
|
/>
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AttributesPage as default } from "@/modules/ee/contacts/attributes/page";
|
||||||
@@ -57,7 +57,7 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
|
|||||||
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
|
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
|
||||||
|
|
||||||
if (organizationProjectsCount >= organizationProjectsLimit) {
|
if (organizationProjectsCount >= organizationProjectsLimit) {
|
||||||
throw new OperationNotAllowedError("Organization project limit reached");
|
throw new OperationNotAllowedError("Organization workspace limit reached");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
|
|
||||||
// Validate that project permission exists for members
|
// Validate that project permission exists for members
|
||||||
if (isMember && !projectPermission) {
|
if (isMember && !projectPermission) {
|
||||||
throw new Error(t("common.project_permission_not_found"));
|
throw new Error(t("common.workspace_permission_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export const MainNavigation = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t("common.configuration"),
|
name: t("common.configuration"),
|
||||||
href: `/environments/${environment.id}/project/general`,
|
href: `/environments/${environment.id}/workspace/general`,
|
||||||
icon: Cog,
|
icon: Cog,
|
||||||
isActive: pathname?.includes("/project"),
|
isActive: pathname?.includes("/project"),
|
||||||
},
|
},
|
||||||
@@ -164,7 +164,7 @@ export const MainNavigation = ({
|
|||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
|
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
|
||||||
!isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded"
|
isCollapsed ? "w-sidebar-expanded" : "w-sidebar-collapsed"
|
||||||
)}>
|
)}>
|
||||||
<div>
|
<div>
|
||||||
{/* Logo and Toggle */}
|
{/* Logo and Toggle */}
|
||||||
@@ -185,7 +185,7 @@ export const MainNavigation = ({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
|
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
|
||||||
)}>
|
)}>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||||
|
|||||||
+6
-6
@@ -17,13 +17,13 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
|
|||||||
const stati = {
|
const stati = {
|
||||||
notImplemented: {
|
notImplemented: {
|
||||||
icon: AlertTriangleIcon,
|
icon: AlertTriangleIcon,
|
||||||
title: t("environments.project.app-connection.formbricks_sdk_not_connected"),
|
title: t("environments.workspace.app-connection.formbricks_sdk_not_connected"),
|
||||||
subtitle: t("environments.project.app-connection.formbricks_sdk_not_connected_description"),
|
subtitle: t("environments.workspace.app-connection.formbricks_sdk_not_connected_description"),
|
||||||
},
|
},
|
||||||
running: {
|
running: {
|
||||||
icon: CheckIcon,
|
icon: CheckIcon,
|
||||||
title: t("environments.project.app-connection.receiving_data"),
|
title: t("environments.workspace.app-connection.receiving_data"),
|
||||||
subtitle: t("environments.project.app-connection.formbricks_sdk_connected"),
|
subtitle: t("environments.workspace.app-connection.formbricks_sdk_connected"),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,11 +53,11 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
|
|||||||
<currentStatus.icon />
|
<currentStatus.icon />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
|
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
|
||||||
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
|
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
|
||||||
{status === "notImplemented" && (
|
{status === "notImplemented" && (
|
||||||
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
||||||
<RotateCcwIcon />
|
<RotateCcwIcon />
|
||||||
{t("environments.project.app-connection.recheck")}
|
{t("environments.workspace.app-connection.recheck")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+9
-3
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import {
|
import {
|
||||||
BuildingIcon,
|
Building2Icon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -144,6 +144,12 @@ export const OrganizationBreadcrumb = ({
|
|||||||
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
|
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
|
||||||
hidden: !isOwnerOrManager,
|
hidden: !isOwnerOrManager,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "domain",
|
||||||
|
label: t("common.domain"),
|
||||||
|
href: `/environments/${currentEnvironmentId}/settings/domain`,
|
||||||
|
hidden: isFormbricksCloud,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "billing",
|
id: "billing",
|
||||||
label: t("common.billing"),
|
label: t("common.billing"),
|
||||||
@@ -166,7 +172,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
id="organizationDropdownTrigger"
|
id="organizationDropdownTrigger"
|
||||||
asChild>
|
asChild>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<BuildingIcon className="h-3 w-3" strokeWidth={1.5} />
|
<Building2Icon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
<span>{organizationName}</span>
|
<span>{organizationName}</span>
|
||||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||||
{isOrganizationDropdownOpen ? (
|
{isOrganizationDropdownOpen ? (
|
||||||
@@ -180,7 +186,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
{showOrganizationDropdown && (
|
{showOrganizationDropdown && (
|
||||||
<>
|
<>
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
<BuildingIcon className="mr-2 inline h-4 w-4" />
|
<Building2Icon className="mr-2 inline h-4 w-4" />
|
||||||
{t("common.choose_organization")}
|
{t("common.choose_organization")}
|
||||||
</div>
|
</div>
|
||||||
{isLoadingOrganizations && (
|
{isLoadingOrganizations && (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
|
import { ChevronDownIcon, ChevronRightIcon, CogIcon, HotelIcon, Loader2, PlusIcon } from "lucide-react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -36,12 +36,12 @@ interface ProjectBreadcrumbProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
||||||
// Match /project/{settingId} or /project/{settingId}/... but exclude settings paths
|
// Match /workspace/{settingId} or /workspace/{settingId}/... but exclude settings paths
|
||||||
if (pathname.includes("/settings/")) {
|
if (pathname.includes("/settings/")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Check if path matches /project/{settingId} (with optional trailing path)
|
// Check if path matches /workspace/{settingId} (with optional trailing path)
|
||||||
const pattern = new RegExp(`/project/${settingId}(?:/|$)`);
|
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
|
||||||
return pattern.test(pathname);
|
return pattern.test(pathname);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
const error = new Error(errorMessage);
|
const error = new Error(errorMessage);
|
||||||
logger.error(error, "Failed to load projects");
|
logger.error(error, "Failed to load projects");
|
||||||
Sentry.captureException(error);
|
Sentry.captureException(error);
|
||||||
setLoadError(errorMessage || t("common.failed_to_load_projects"));
|
setLoadError(errorMessage || t("common.failed_to_load_workspaces"));
|
||||||
}
|
}
|
||||||
setIsLoadingProjects(false);
|
setIsLoadingProjects(false);
|
||||||
});
|
});
|
||||||
@@ -101,42 +101,42 @@ export const ProjectBreadcrumb = ({
|
|||||||
{
|
{
|
||||||
id: "general",
|
id: "general",
|
||||||
label: t("common.general"),
|
label: t("common.general"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/general`,
|
href: `/environments/${currentEnvironmentId}/workspace/general`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "look",
|
id: "look",
|
||||||
label: t("common.look_and_feel"),
|
label: t("common.look_and_feel"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/look`,
|
href: `/environments/${currentEnvironmentId}/workspace/look`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "app-connection",
|
id: "app-connection",
|
||||||
label: t("common.website_and_app_connection"),
|
label: t("common.website_and_app_connection"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/app-connection`,
|
href: `/environments/${currentEnvironmentId}/workspace/app-connection`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "integrations",
|
id: "integrations",
|
||||||
label: t("common.integrations"),
|
label: t("common.integrations"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/integrations`,
|
href: `/environments/${currentEnvironmentId}/workspace/integrations`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "teams",
|
id: "teams",
|
||||||
label: t("common.team_access"),
|
label: t("common.team_access"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/teams`,
|
href: `/environments/${currentEnvironmentId}/workspace/teams`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "languages",
|
id: "languages",
|
||||||
label: t("common.survey_languages"),
|
label: t("common.survey_languages"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/languages`,
|
href: `/environments/${currentEnvironmentId}/workspace/languages`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "tags",
|
id: "tags",
|
||||||
label: t("common.tags"),
|
label: t("common.tags"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/tags`,
|
href: `/environments/${currentEnvironmentId}/workspace/tags`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!currentProject) {
|
if (!currentProject) {
|
||||||
const errorMessage = `Project not found for project id: ${currentProjectId}`;
|
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
|
||||||
logger.error(errorMessage);
|
logger.error(errorMessage);
|
||||||
Sentry.captureException(new Error(errorMessage));
|
Sentry.captureException(new Error(errorMessage));
|
||||||
return;
|
return;
|
||||||
@@ -145,7 +145,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
const handleProjectChange = (projectId: string) => {
|
const handleProjectChange = (projectId: string) => {
|
||||||
if (projectId === currentProjectId) return;
|
if (projectId === currentProjectId) return;
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.push(`/projects/${projectId}/`);
|
router.push(`/workspaces/${projectId}/`);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
|
|
||||||
const handleProjectSettingsNavigation = (settingId: string) => {
|
const handleProjectSettingsNavigation = (settingId: string) => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.push(`/environments/${currentEnvironmentId}/project/${settingId}`);
|
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -198,21 +198,21 @@ export const ProjectBreadcrumb = ({
|
|||||||
id="projectDropdownTrigger"
|
id="projectDropdownTrigger"
|
||||||
asChild>
|
asChild>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<FolderOpenIcon className="h-3 w-3" strokeWidth={1.5} />
|
<HotelIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
<span>{projectName}</span>
|
<span>{projectName}</span>
|
||||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||||
{isProjectDropdownOpen ? (
|
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
|
||||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
) : (
|
) : (
|
||||||
isEnvironmentBreadcrumbVisible && <ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
|
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent align="start" className="mt-2">
|
<DropdownMenuContent align="start" className="mt-2">
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
<FolderOpenIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||||
{t("common.choose_project")}
|
{t("common.choose_workspace")}
|
||||||
</div>
|
</div>
|
||||||
{isLoadingProjects && (
|
{isLoadingProjects && (
|
||||||
<div className="flex items-center justify-center py-2">
|
<div className="flex items-center justify-center py-2">
|
||||||
@@ -251,7 +251,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
onClick={handleAddProject}
|
onClick={handleAddProject}
|
||||||
className="w-full cursor-pointer justify-between">
|
className="w-full cursor-pointer justify-between">
|
||||||
<span>{t("common.add_new_project")}</span>
|
<span>{t("common.add_new_workspace")}</span>
|
||||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||||
</DropdownMenuCheckboxItem>
|
</DropdownMenuCheckboxItem>
|
||||||
)}
|
)}
|
||||||
@@ -261,7 +261,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||||
{t("common.project_configuration")}
|
{t("common.workspace_configuration")}
|
||||||
</div>
|
</div>
|
||||||
{projectSettings.map((setting) => (
|
{projectSettings.map((setting) => (
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
|
|||||||
-43
@@ -1,43 +0,0 @@
|
|||||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
|
||||||
|
|
||||||
export const TYPE_MAPPING = {
|
|
||||||
[TSurveyQuestionTypeEnum.CTA]: ["checkbox"],
|
|
||||||
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: ["multi_select"],
|
|
||||||
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: ["select", "status"],
|
|
||||||
[TSurveyQuestionTypeEnum.OpenText]: [
|
|
||||||
"created_by",
|
|
||||||
"created_time",
|
|
||||||
"email",
|
|
||||||
"last_edited_by",
|
|
||||||
"last_edited_time",
|
|
||||||
"number",
|
|
||||||
"phone_number",
|
|
||||||
"rich_text",
|
|
||||||
"title",
|
|
||||||
"url",
|
|
||||||
],
|
|
||||||
[TSurveyQuestionTypeEnum.NPS]: ["number"],
|
|
||||||
[TSurveyQuestionTypeEnum.Consent]: ["checkbox"],
|
|
||||||
[TSurveyQuestionTypeEnum.Rating]: ["number"],
|
|
||||||
[TSurveyQuestionTypeEnum.PictureSelection]: ["url"],
|
|
||||||
[TSurveyQuestionTypeEnum.FileUpload]: ["url"],
|
|
||||||
[TSurveyQuestionTypeEnum.Date]: ["date"],
|
|
||||||
[TSurveyQuestionTypeEnum.Address]: ["rich_text"],
|
|
||||||
[TSurveyQuestionTypeEnum.Matrix]: ["rich_text"],
|
|
||||||
[TSurveyQuestionTypeEnum.Cal]: ["checkbox"],
|
|
||||||
[TSurveyQuestionTypeEnum.ContactInfo]: ["rich_text"],
|
|
||||||
[TSurveyQuestionTypeEnum.Ranking]: ["rich_text"],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const UNSUPPORTED_TYPES_BY_NOTION = [
|
|
||||||
"rollup",
|
|
||||||
"created_by",
|
|
||||||
"created_time",
|
|
||||||
"last_edited_by",
|
|
||||||
"last_edited_time",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const ERRORS = {
|
|
||||||
MAPPING: "Mapping Error",
|
|
||||||
UNSUPPORTED_TYPE: "Unsupported type by Notion",
|
|
||||||
};
|
|
||||||
@@ -21,7 +21,7 @@ const AccountSettingsLayout = async (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error(t("common.project_not_found"));
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ export const IntegrationsTip = ({ environmentId }: IntegrationsTipProps) => {
|
|||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
{t("environments.settings.notifications.need_slack_or_discord_notifications")}?
|
{t("environments.settings.notifications.need_slack_or_discord_notifications")}?
|
||||||
<a
|
<a
|
||||||
href={`/environments/${environmentId}/project/integrations`}
|
href={`/environments/${environmentId}/workspace/integrations`}
|
||||||
className="ml-1 cursor-pointer text-sm underline">
|
className="ml-1 cursor-pointer text-sm underline">
|
||||||
{t("environments.settings.notifications.use_the_integration")}
|
{t("environments.settings.notifications.use_the_integration")}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
+7
@@ -47,6 +47,13 @@ export const OrganizationSettingsNavbar = ({
|
|||||||
current: pathname?.includes("/api-keys"),
|
current: pathname?.includes("/api-keys"),
|
||||||
hidden: !isOwner,
|
hidden: !isOwner,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "domain",
|
||||||
|
label: t("common.domain"),
|
||||||
|
href: `/environments/${environmentId}/settings/domain`,
|
||||||
|
current: pathname?.includes("/domain"),
|
||||||
|
hidden: isFormbricksCloud,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "billing",
|
id: "billing",
|
||||||
label: t("common.billing"),
|
label: t("common.billing"),
|
||||||
|
|||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TSurveyStatus } from "@formbricks/types/surveys/types";
|
||||||
|
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||||
|
|
||||||
|
interface SurveyWithSlug {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string | null;
|
||||||
|
status: TSurveyStatus;
|
||||||
|
environment: {
|
||||||
|
id: string;
|
||||||
|
type: "production" | "development";
|
||||||
|
project: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PrettyUrlsTableProps {
|
||||||
|
surveys: SurveyWithSlug[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const getEnvironmentBadgeColor = (type: string) => {
|
||||||
|
return type === "production" ? "bg-green-100 text-green-800" : "bg-blue-100 text-blue-800";
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableHeaders = [
|
||||||
|
{
|
||||||
|
label: t("environments.settings.domain.survey_name"),
|
||||||
|
key: "name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("environments.settings.domain.workspace"),
|
||||||
|
key: "project",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("environments.settings.domain.pretty_url"),
|
||||||
|
key: "slug",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("common.environment"),
|
||||||
|
key: "environment",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-lg">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-slate-100">
|
||||||
|
{tableHeaders.map((header) => (
|
||||||
|
<TableHead key={header.key} className="font-medium text-slate-500">
|
||||||
|
{header.label}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody className="[&_tr:last-child]:border-b">
|
||||||
|
{surveys.length === 0 && (
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableCell colSpan={4} className="text-center text-slate-500">
|
||||||
|
{t("environments.settings.domain.no_pretty_urls")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{surveys.map((survey) => (
|
||||||
|
<TableRow key={survey.id} className="border-slate-200 hover:bg-transparent">
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<Link
|
||||||
|
href={`/environments/${survey.environment.id}/surveys/${survey.id}/summary`}
|
||||||
|
className="text-slate-900 hover:text-slate-700 hover:underline">
|
||||||
|
{survey.name}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{survey.environment.project.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<IdBadge id={survey.slug ?? ""} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
className={`rounded px-2 py-1 text-xs font-medium ${getEnvironmentBadgeColor(survey.environment.type)}`}>
|
||||||
|
{survey.environment.type === "production"
|
||||||
|
? t("common.production")
|
||||||
|
: t("common.development")}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||||
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
|
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
||||||
|
import { FaviconCustomizationSettings } from "@/modules/ee/whitelabel/favicon-customization/components/favicon-customization-settings";
|
||||||
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
|
import { getSurveysWithSlugsByOrganizationId } from "@/modules/survey/lib/slug";
|
||||||
|
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 { SettingsCard } from "../../components/SettingsCard";
|
||||||
|
import { OrganizationSettingsNavbar } from "../components/OrganizationSettingsNavbar";
|
||||||
|
import { PrettyUrlsTable } from "./components/pretty-urls-table";
|
||||||
|
|
||||||
|
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||||
|
const params = await props.params;
|
||||||
|
const t = await getTranslate();
|
||||||
|
|
||||||
|
if (IS_FORMBRICKS_CLOUD) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { session, currentUserMembership, organization, isOwner, isManager } = await getEnvironmentAuth(
|
||||||
|
params.environmentId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(t("common.session_not_found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
|
||||||
|
const isOwnerOrManager = isManager || isOwner;
|
||||||
|
|
||||||
|
const surveys = await getSurveysWithSlugsByOrganizationId(organization.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContentWrapper>
|
||||||
|
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
|
||||||
|
<OrganizationSettingsNavbar
|
||||||
|
environmentId={params.environmentId}
|
||||||
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
|
membershipRole={currentUserMembership?.role}
|
||||||
|
activeId="domain"
|
||||||
|
/>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{!IS_STORAGE_CONFIGURED && (
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription>{t("common.storage_not_configured")}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FaviconCustomizationSettings
|
||||||
|
organization={organization}
|
||||||
|
hasWhiteLabelPermission={hasWhiteLabelPermission}
|
||||||
|
environmentId={params.environmentId}
|
||||||
|
isReadOnly={!isOwnerOrManager}
|
||||||
|
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title={t("environments.settings.domain.title")}
|
||||||
|
description={t("environments.settings.domain.description")}>
|
||||||
|
<PrettyUrlsTable surveys={surveys} />
|
||||||
|
</SettingsCard>
|
||||||
|
</PageContentWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
||||||
+2
-2
@@ -39,7 +39,7 @@ const Page = async (props) => {
|
|||||||
onRequest: false,
|
onRequest: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("environments.project.languages.multi_language_surveys"),
|
title: t("environments.workspace.languages.multi_language_surveys"),
|
||||||
comingSoon: false,
|
comingSoon: false,
|
||||||
onRequest: false,
|
onRequest: false,
|
||||||
},
|
},
|
||||||
@@ -118,7 +118,7 @@ const Page = async (props) => {
|
|||||||
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 1024 1024"
|
viewBox="0 0 1024 1024"
|
||||||
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
className="absolute top-1/2 left-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
||||||
aria-hidden="true">
|
aria-hidden="true">
|
||||||
<circle
|
<circle
|
||||||
cx={512}
|
cx={512}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const Layout = async (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error(t("common.project_not_found"));
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ export const EmptyAppSurveys = ({ environment }: TEmptyAppSurveysProps) => {
|
|||||||
{t("environments.surveys.summary.connect_your_website_or_app_with_formbricks_to_get_started")}
|
{t("environments.surveys.summary.connect_your_website_or_app_with_formbricks_to_get_started")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Link className="mt-2" href={`/environments/${environment.id}/project/app-connection`}>
|
<Link className="mt-2" href={`/environments/${environment.id}/workspace/app-connection`}>
|
||||||
<Button size="sm" className="flex w-[120px] justify-center">
|
<Button size="sm" className="flex w-[120px] justify-center">
|
||||||
{t("common.connect")}
|
{t("common.connect")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+1
@@ -213,6 +213,7 @@ export const SurveyAnalysisCTA = ({
|
|||||||
isFormbricksCloud={isFormbricksCloud}
|
isFormbricksCloud={isFormbricksCloud}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
|
projectCustomScripts={project.customHeadScripts}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<SuccessMessage environment={environment} survey={survey} />
|
<SuccessMessage environment={environment} survey={survey} />
|
||||||
|
|||||||
+52
-20
@@ -3,7 +3,8 @@
|
|||||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||||
import {
|
import {
|
||||||
Code2Icon,
|
Code2Icon,
|
||||||
LinkIcon,
|
CodeIcon,
|
||||||
|
Link2Icon,
|
||||||
MailIcon,
|
MailIcon,
|
||||||
QrCodeIcon,
|
QrCodeIcon,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -18,10 +19,12 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
|||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
import { AnonymousLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab";
|
import { AnonymousLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab";
|
||||||
import { AppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab";
|
import { AppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab";
|
||||||
|
import { CustomHtmlTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/custom-html-tab";
|
||||||
import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab";
|
import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab";
|
||||||
import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab";
|
import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab";
|
||||||
import { LinkSettingsTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/link-settings-tab";
|
import { LinkSettingsTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/link-settings-tab";
|
||||||
import { PersonalLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab";
|
import { PersonalLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab";
|
||||||
|
import { PrettyUrlTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/pretty-url-tab";
|
||||||
import { QRCodeTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab";
|
import { QRCodeTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab";
|
||||||
import { SocialMediaTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab";
|
import { SocialMediaTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab";
|
||||||
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
|
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
|
||||||
@@ -50,6 +53,7 @@ interface ShareSurveyModalProps {
|
|||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
isStorageConfigured: boolean;
|
isStorageConfigured: boolean;
|
||||||
|
projectCustomScripts?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShareSurveyModal = ({
|
export const ShareSurveyModal = ({
|
||||||
@@ -64,6 +68,7 @@ export const ShareSurveyModal = ({
|
|||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
isStorageConfigured,
|
isStorageConfigured,
|
||||||
|
projectCustomScripts,
|
||||||
}: ShareSurveyModalProps) => {
|
}: ShareSurveyModalProps) => {
|
||||||
const environmentId = survey.environmentId;
|
const environmentId = survey.environmentId;
|
||||||
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
|
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
|
||||||
@@ -80,13 +85,13 @@ export const ShareSurveyModal = ({
|
|||||||
componentType: React.ComponentType<unknown>;
|
componentType: React.ComponentType<unknown>;
|
||||||
componentProps: unknown;
|
componentProps: unknown;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}[] = useMemo(
|
}[] = useMemo(() => {
|
||||||
() => [
|
const tabs = [
|
||||||
{
|
{
|
||||||
id: ShareViaType.ANON_LINKS,
|
id: ShareViaType.ANON_LINKS,
|
||||||
type: LinkTabsType.SHARE_VIA,
|
type: LinkTabsType.SHARE_VIA,
|
||||||
label: t("environments.surveys.share.anonymous_links.nav_title"),
|
label: t("environments.surveys.share.anonymous_links.nav_title"),
|
||||||
icon: LinkIcon,
|
icon: Link2Icon,
|
||||||
title: t("environments.surveys.share.anonymous_links.nav_title"),
|
title: t("environments.surveys.share.anonymous_links.nav_title"),
|
||||||
description: t("environments.surveys.share.anonymous_links.description"),
|
description: t("environments.surveys.share.anonymous_links.description"),
|
||||||
componentType: AnonymousLinksTab,
|
componentType: AnonymousLinksTab,
|
||||||
@@ -180,22 +185,49 @@ export const ShareSurveyModal = ({
|
|||||||
componentType: LinkSettingsTab,
|
componentType: LinkSettingsTab,
|
||||||
componentProps: { isReadOnly, locale: user.locale, isStorageConfigured },
|
componentProps: { isReadOnly, locale: user.locale, isStorageConfigured },
|
||||||
},
|
},
|
||||||
],
|
{
|
||||||
[
|
id: ShareSettingsType.PRETTY_URL,
|
||||||
t,
|
type: LinkTabsType.SHARE_SETTING,
|
||||||
survey,
|
label: t("environments.surveys.share.pretty_url.title"),
|
||||||
publicDomain,
|
icon: Link2Icon,
|
||||||
user.locale,
|
title: t("environments.surveys.share.pretty_url.title"),
|
||||||
surveyUrl,
|
description: t("environments.surveys.share.pretty_url.description"),
|
||||||
isReadOnly,
|
componentType: PrettyUrlTab,
|
||||||
environmentId,
|
componentProps: { publicDomain, isReadOnly },
|
||||||
segments,
|
},
|
||||||
isContactsEnabled,
|
{
|
||||||
isFormbricksCloud,
|
id: ShareSettingsType.CUSTOM_HTML,
|
||||||
email,
|
type: LinkTabsType.SHARE_SETTING,
|
||||||
isStorageConfigured,
|
label: t("environments.surveys.share.custom_html.nav_title"),
|
||||||
]
|
icon: CodeIcon,
|
||||||
);
|
title: t("environments.surveys.share.custom_html.nav_title"),
|
||||||
|
description: t("environments.surveys.share.custom_html.description"),
|
||||||
|
componentType: CustomHtmlTab,
|
||||||
|
componentProps: { projectCustomScripts, isReadOnly },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Filter out tabs that should not be shown on Formbricks Cloud
|
||||||
|
return isFormbricksCloud
|
||||||
|
? tabs.filter(
|
||||||
|
(tab) => tab.id !== ShareSettingsType.PRETTY_URL && tab.id !== ShareSettingsType.CUSTOM_HTML
|
||||||
|
)
|
||||||
|
: tabs;
|
||||||
|
}, [
|
||||||
|
t,
|
||||||
|
survey,
|
||||||
|
publicDomain,
|
||||||
|
user.locale,
|
||||||
|
surveyUrl,
|
||||||
|
isReadOnly,
|
||||||
|
environmentId,
|
||||||
|
segments,
|
||||||
|
isContactsEnabled,
|
||||||
|
isFormbricksCloud,
|
||||||
|
email,
|
||||||
|
isStorageConfigured,
|
||||||
|
projectCustomScripts,
|
||||||
|
]);
|
||||||
|
|
||||||
const getDefaultActiveId = useCallback(() => {
|
const getDefaultActiveId = useCallback(() => {
|
||||||
if (survey.type !== "link") {
|
if (survey.type !== "link") {
|
||||||
|
|||||||
+12
-3
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { CirclePlayIcon, CopyIcon } from "lucide-react";
|
import { CirclePlayIcon, CopyIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
@@ -34,7 +34,6 @@ export const AnonymousLinksTab = ({
|
|||||||
locale,
|
locale,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
}: AnonymousLinksTabProps) => {
|
}: AnonymousLinksTabProps) => {
|
||||||
const surveyUrlWithCustomSuid = `${surveyUrl}?suId=CUSTOM-ID`;
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -49,6 +48,12 @@ export const AnonymousLinksTab = ({
|
|||||||
pendingAction: () => Promise<void> | void;
|
pendingAction: () => Promise<void> | void;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
const surveyUrlWithCustomSuid = useMemo(() => {
|
||||||
|
const url = new URL(surveyUrl);
|
||||||
|
url.searchParams.set("suId", "CUSTOM-ID");
|
||||||
|
return url.toString();
|
||||||
|
}, [surveyUrl]);
|
||||||
|
|
||||||
const resetState = () => {
|
const resetState = () => {
|
||||||
const { singleUse } = survey;
|
const { singleUse } = survey;
|
||||||
const { enabled, isEncrypted } = singleUse ?? {};
|
const { enabled, isEncrypted } = singleUse ?? {};
|
||||||
@@ -177,7 +182,11 @@ export const AnonymousLinksTab = ({
|
|||||||
|
|
||||||
if (!!response?.data?.length) {
|
if (!!response?.data?.length) {
|
||||||
const singleUseIds = response.data;
|
const singleUseIds = response.data;
|
||||||
const surveyLinks = singleUseIds.map((singleUseId) => `${surveyUrl}?suId=${singleUseId}`);
|
const surveyLinks = singleUseIds.map((singleUseId) => {
|
||||||
|
const url = new URL(surveyUrl);
|
||||||
|
url.searchParams.set("suId", singleUseId);
|
||||||
|
return url.toString();
|
||||||
|
});
|
||||||
|
|
||||||
// Create content with just the links
|
// Create content with just the links
|
||||||
const csvContent = surveyLinks.join("\n");
|
const csvContent = surveyLinks.join("\n");
|
||||||
|
|||||||
+1
-1
@@ -163,7 +163,7 @@ export const AppTab = () => {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
{!environment.appSetupCompleted && (
|
{!environment.appSetupCompleted && (
|
||||||
<AlertButton asChild>
|
<AlertButton asChild>
|
||||||
<Link href={`/environments/${environment.id}/project/app-connection`}>
|
<Link href={`/environments/${environment.id}/workspace/app-connection`}>
|
||||||
{t("common.connect_formbricks")}
|
{t("common.connect_formbricks")}
|
||||||
</Link>
|
</Link>
|
||||||
</AlertButton>
|
</AlertButton>
|
||||||
|
|||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AlertTriangleIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
|
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { updateSurveyAction } from "@/modules/survey/editor/actions";
|
||||||
|
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormProvider,
|
||||||
|
} from "@/modules/ui/components/form";
|
||||||
|
import { TabToggle } from "@/modules/ui/components/tab-toggle";
|
||||||
|
|
||||||
|
interface CustomHtmlTabProps {
|
||||||
|
projectCustomScripts: string | null | undefined;
|
||||||
|
isReadOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomHtmlFormData {
|
||||||
|
customHeadScripts: string;
|
||||||
|
customHeadScriptsMode: TSurvey["customHeadScriptsMode"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTabProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { survey } = useSurvey();
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<CustomHtmlFormData>({
|
||||||
|
defaultValues: {
|
||||||
|
customHeadScripts: survey.customHeadScripts ?? "",
|
||||||
|
customHeadScriptsMode: survey.customHeadScriptsMode ?? "add",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
reset,
|
||||||
|
formState: { isDirty },
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
const scriptsMode = watch("customHeadScriptsMode");
|
||||||
|
|
||||||
|
const onSubmit = async (data: CustomHtmlFormData) => {
|
||||||
|
if (isSaving || isReadOnly) return;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
const updatedSurvey: TSurvey = {
|
||||||
|
...survey,
|
||||||
|
customHeadScripts: data.customHeadScripts || null,
|
||||||
|
customHeadScriptsMode: data.customHeadScriptsMode,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await updateSurveyAction(updatedSurvey);
|
||||||
|
|
||||||
|
if (result?.data) {
|
||||||
|
toast.success(t("environments.surveys.share.custom_html.saved_successfully"));
|
||||||
|
reset(data);
|
||||||
|
} else {
|
||||||
|
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-1">
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
{/* Mode Toggle */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel>{t("environments.surveys.share.custom_html.script_mode")}</FormLabel>
|
||||||
|
<TabToggle
|
||||||
|
id="custom-scripts-mode"
|
||||||
|
options={[
|
||||||
|
{ value: "add", label: t("environments.surveys.share.custom_html.add_to_workspace") },
|
||||||
|
{ value: "replace", label: t("environments.surveys.share.custom_html.replace_workspace") },
|
||||||
|
]}
|
||||||
|
defaultSelected={scriptsMode ?? "add"}
|
||||||
|
onChange={(value) => setValue("customHeadScriptsMode", value, { shouldDirty: true })}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{scriptsMode === "add"
|
||||||
|
? t("environments.surveys.share.custom_html.add_mode_description")
|
||||||
|
: t("environments.surveys.share.custom_html.replace_mode_description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workspace Scripts Preview */}
|
||||||
|
{projectCustomScripts && (
|
||||||
|
<div className={scriptsMode === "replace" ? "opacity-50" : ""}>
|
||||||
|
<FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel>
|
||||||
|
<div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3">
|
||||||
|
<pre className="font-mono text-xs whitespace-pre-wrap text-slate-600">
|
||||||
|
{projectCustomScripts}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!projectCustomScripts && (
|
||||||
|
<div className="rounded-md border border-slate-200 bg-slate-50 p-3">
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{t("environments.surveys.share.custom_html.no_workspace_scripts")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Survey Scripts */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="customHeadScripts"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("environments.surveys.share.custom_html.survey_scripts_label")}</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
{t("environments.surveys.share.custom_html.survey_scripts_description")}
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<textarea
|
||||||
|
rows={8}
|
||||||
|
placeholder={t("environments.surveys.share.custom_html.placeholder")}
|
||||||
|
className={cn(
|
||||||
|
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
)}
|
||||||
|
{...field}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<Button type="submit" disabled={isSaving || isReadOnly || !isDirty}>
|
||||||
|
{isSaving ? t("common.saving") : t("common.save")}
|
||||||
|
</Button>
|
||||||
|
{/* Security Warning */}
|
||||||
|
<Alert variant="warning" className="flex items-start gap-2">
|
||||||
|
<AlertTriangleIcon className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<AlertDescription>
|
||||||
|
{t("environments.surveys.share.custom_html.security_warning")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
|
||||||
|
interface PrettyUrlInputProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
publicDomain: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PrettyUrlInput = ({ value, onChange, publicDomain, disabled = false }: PrettyUrlInputProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center overflow-hidden rounded-md border border-slate-300 bg-white">
|
||||||
|
<span className="flex-shrink-0 border-r border-slate-300 bg-slate-50 px-3 py-2 text-sm text-slate-600">
|
||||||
|
{publicDomain}/p/
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value.toLowerCase().replaceAll(/[^a-z0-9-]/g, ""))}
|
||||||
|
placeholder={t("environments.surveys.share.pretty_url.slug_placeholder")}
|
||||||
|
disabled={disabled}
|
||||||
|
className="border-0 bg-white focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+197
@@ -0,0 +1,197 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Copy, Trash2 } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
||||||
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
|
import { removeSurveySlugAction, updateSurveySlugAction } from "@/modules/survey/slug/actions";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormProvider,
|
||||||
|
} from "@/modules/ui/components/form";
|
||||||
|
import { PrettyUrlInput } from "./pretty-url-input";
|
||||||
|
|
||||||
|
interface PrettyUrlTabProps {
|
||||||
|
publicDomain: string;
|
||||||
|
isReadOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PrettyUrlFormData {
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PrettyUrlTab = ({ publicDomain, isReadOnly = false }: PrettyUrlTabProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const { survey } = useSurvey();
|
||||||
|
const [isEditing, setIsEditing] = useState(!survey.slug);
|
||||||
|
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Initialize form with current values - memoize to prevent re-initialization
|
||||||
|
const initialFormData = useMemo(() => {
|
||||||
|
return {
|
||||||
|
slug: survey.slug || "",
|
||||||
|
};
|
||||||
|
}, [survey.slug]);
|
||||||
|
|
||||||
|
const form = useForm<PrettyUrlFormData>({
|
||||||
|
defaultValues: initialFormData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleSubmit, reset } = form;
|
||||||
|
|
||||||
|
// Sync isEditing state and form with survey.slug changes
|
||||||
|
useEffect(() => {
|
||||||
|
setIsEditing(!survey.slug);
|
||||||
|
reset({ slug: survey.slug || "" });
|
||||||
|
}, [survey.slug, reset]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: PrettyUrlFormData) => {
|
||||||
|
if (!data.slug.trim()) {
|
||||||
|
toast.error(t("environments.surveys.share.pretty_url.slug_required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await updateSurveySlugAction({
|
||||||
|
surveyId: survey.id,
|
||||||
|
slug: data.slug,
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorMessage = getFormattedErrorMessage(result);
|
||||||
|
if (errorMessage) {
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} else {
|
||||||
|
toast.success(t("environments.surveys.share.pretty_url.save_success"));
|
||||||
|
router.refresh();
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : t("common.something_went_wrong_please_try_again");
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
reset({ slug: survey.slug || "" });
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async () => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await removeSurveySlugAction({ surveyId: survey.id });
|
||||||
|
|
||||||
|
const errorMessage = getFormattedErrorMessage(result);
|
||||||
|
if (errorMessage) {
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} else {
|
||||||
|
setShowRemoveDialog(false);
|
||||||
|
reset({ slug: "" });
|
||||||
|
router.refresh();
|
||||||
|
setIsEditing(true);
|
||||||
|
toast.success(t("environments.surveys.share.pretty_url.remove_success"));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : t("common.something_went_wrong_please_try_again");
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyUrl = () => {
|
||||||
|
if (!survey.slug) return;
|
||||||
|
const prettyUrl = `${publicDomain}/p/${survey.slug}`;
|
||||||
|
navigator.clipboard.writeText(prettyUrl);
|
||||||
|
toast.success(t("common.copied_to_clipboard"));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-1">
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="slug"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("environments.surveys.share.pretty_url.slug_label")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<PrettyUrlInput
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
publicDomain={publicDomain}
|
||||||
|
disabled={isReadOnly || !isEditing}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>{t("environments.surveys.share.pretty_url.slug_help")}</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button type="submit" disabled={isReadOnly || isSubmitting}>
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
{survey.slug && (
|
||||||
|
<Button type="button" variant="secondary" onClick={handleCancel} disabled={isSubmitting}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button type="button" variant="secondary" onClick={handleEdit} disabled={isReadOnly}>
|
||||||
|
{t("common.edit")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{survey.slug && !isEditing && (
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="default" onClick={handleCopyUrl} disabled={isReadOnly}>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
{t("common.copy")} URL
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setShowRemoveDialog(true)}
|
||||||
|
disabled={isReadOnly}>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{t("common.remove")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
|
||||||
|
<DeleteDialog
|
||||||
|
open={showRemoveDialog}
|
||||||
|
setOpen={setShowRemoveDialog}
|
||||||
|
deleteWhat={t("environments.surveys.share.pretty_url.title")}
|
||||||
|
onDelete={handleRemove}
|
||||||
|
text={t("environments.surveys.share.pretty_url.remove_description")}></DeleteDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+2
-2
@@ -66,7 +66,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
|
|||||||
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
|
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
|
||||||
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
||||||
{t("environments.surveys.summary.use_personal_links")}
|
{t("environments.surveys.summary.use_personal_links")}
|
||||||
<Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} />
|
<Badge size="normal" type="success" className="absolute top-3 right-3" text={t("common.new")} />
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href={`/environments/${environmentId}/settings/notifications`}
|
href={`/environments/${environmentId}/settings/notifications`}
|
||||||
@@ -75,7 +75,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
|
|||||||
{t("environments.surveys.summary.configure_alerts")}
|
{t("environments.surveys.summary.configure_alerts")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={`/environments/${environmentId}/project/integrations`}
|
href={`/environments/${environmentId}/workspace/integrations`}
|
||||||
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
|
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
|
||||||
<BlocksIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
<BlocksIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
||||||
{t("environments.surveys.summary.setup_integrations")}
|
{t("environments.surveys.summary.setup_integrations")}
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
|
|||||||
}
|
}
|
||||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error("Project not found");
|
throw new Error("Workspace not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const styling = getStyling(project, survey);
|
const styling = getStyling(project, survey);
|
||||||
|
|||||||
+2
@@ -12,6 +12,8 @@ export enum ShareViaType {
|
|||||||
|
|
||||||
export enum ShareSettingsType {
|
export enum ShareSettingsType {
|
||||||
LINK_SETTINGS = "link-settings",
|
LINK_SETTINGS = "link-settings",
|
||||||
|
PRETTY_URL = "pretty-url",
|
||||||
|
CUSTOM_HTML = "custom-html",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum LinkTabsType {
|
export enum LinkTabsType {
|
||||||
|
|||||||
+10
-10
@@ -17,9 +17,9 @@ import {
|
|||||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/BaseSelectDropdown";
|
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/BaseSelectDropdown";
|
||||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
|
import { fetchTables } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
|
||||||
import AirtableLogo from "@/images/airtableLogo.svg";
|
import AirtableLogo from "@/images/airtableLogo.svg";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
@@ -58,12 +58,12 @@ const ElementCheckbox = ({
|
|||||||
onChange: (value: string[]) => void;
|
onChange: (value: string[]) => void;
|
||||||
};
|
};
|
||||||
}) => {
|
}) => {
|
||||||
const handleCheckedChange = (checked: boolean) => {
|
const addElement = () => {
|
||||||
if (checked) {
|
field.onChange([...(field.value || []), element.id]);
|
||||||
field.onChange([...(field.value || []), element.id]);
|
};
|
||||||
} else {
|
|
||||||
field.onChange(field.value?.filter((value) => value !== element.id) || []);
|
const removeElement = () => {
|
||||||
}
|
field.onChange(field.value?.filter((value) => value !== element.id) || []);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -75,7 +75,7 @@ const ElementCheckbox = ({
|
|||||||
value={element.id}
|
value={element.id}
|
||||||
className="bg-white"
|
className="bg-white"
|
||||||
checked={field.value?.includes(element.id)}
|
checked={field.value?.includes(element.id)}
|
||||||
onCheckedChange={handleCheckedChange}
|
onCheckedChange={(checked) => (checked ? addElement() : removeElement())}
|
||||||
/>
|
/>
|
||||||
<span className="ml-2">
|
<span className="ml-2">
|
||||||
{getTextContent(recallToHeadline(element.headline, selectedSurvey, false, "default")["default"])}
|
{getTextContent(recallToHeadline(element.headline, selectedSurvey, false, "default")["default"])}
|
||||||
+2
-2
@@ -5,8 +5,8 @@ import { TIntegrationItem } from "@formbricks/types/integration";
|
|||||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/ManageIntegration";
|
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/ManageIntegration";
|
||||||
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
|
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
|
||||||
import airtableLogo from "@/images/airtableLogo.svg";
|
import airtableLogo from "@/images/airtableLogo.svg";
|
||||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||||
|
|
||||||
+2
-2
@@ -8,8 +8,8 @@ import { TIntegrationItem } from "@formbricks/types/integration";
|
|||||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AddIntegrationModal";
|
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AddIntegrationModal";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||||
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AirtableWrapper";
|
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||||
import { getAirtableTables } from "@/lib/airtable/service";
|
import { getAirtableTables } from "@/lib/airtable/service";
|
||||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||||
import { getIntegrations } from "@/lib/integration/service";
|
import { getIntegrations } from "@/lib/integration/service";
|
||||||
@@ -42,7 +42,7 @@ const Page = async (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/project/integrations`} />
|
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
|
||||||
<PageHeader pageTitle={t("environments.integrations.airtable.airtable_integration")} />
|
<PageHeader pageTitle={t("environments.integrations.airtable.airtable_integration")} />
|
||||||
<div className="h-[75vh] w-full">
|
<div className="h-[75vh] w-full">
|
||||||
<AirtableWrapper
|
<AirtableWrapper
|
||||||
+4
-4
@@ -12,13 +12,13 @@ import {
|
|||||||
} from "@formbricks/types/integration/google-sheet";
|
} from "@formbricks/types/integration/google-sheet";
|
||||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/actions";
|
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions";
|
||||||
import {
|
import {
|
||||||
constructGoogleSheetsUrl,
|
constructGoogleSheetsUrl,
|
||||||
extractSpreadsheetIdFromUrl,
|
extractSpreadsheetIdFromUrl,
|
||||||
isValidGoogleSheetsUrl,
|
isValidGoogleSheetsUrl,
|
||||||
} from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/util";
|
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/util";
|
||||||
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
|
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
@@ -266,7 +266,7 @@ export const AddIntegrationModal = ({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||||
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
|
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
|
||||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||||
{surveyElements.map((question) => (
|
{surveyElements.map((question) => (
|
||||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||||
+2
-2
@@ -8,8 +8,8 @@ import {
|
|||||||
} from "@formbricks/types/integration/google-sheet";
|
} from "@formbricks/types/integration/google-sheet";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/ManageIntegration";
|
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/ManageIntegration";
|
||||||
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/google";
|
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google";
|
||||||
import googleSheetLogo from "@/images/googleSheetsLogo.png";
|
import googleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||||
import { AddIntegrationModal } from "./AddIntegrationModal";
|
import { AddIntegrationModal } from "./AddIntegrationModal";
|
||||||
+1
-1
@@ -9,7 +9,7 @@ import {
|
|||||||
TIntegrationGoogleSheetsConfigData,
|
TIntegrationGoogleSheetsConfigData,
|
||||||
} from "@formbricks/types/integration/google-sheet";
|
} from "@formbricks/types/integration/google-sheet";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
+2
-2
@@ -10,7 +10,7 @@ const Loading = () => {
|
|||||||
<div className="mt-6 p-6">
|
<div className="mt-6 p-6">
|
||||||
<GoBackButton />
|
<GoBackButton />
|
||||||
<div className="mb-6 text-right">
|
<div className="mb-6 text-right">
|
||||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
|
||||||
{t("environments.integrations.google_sheets.link_new_sheet")}
|
{t("environments.integrations.google_sheets.link_new_sheet")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +51,7 @@ const Loading = () => {
|
|||||||
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
|
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
|
||||||
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
|
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center"></div>
|
<div className="text-center"></div>
|
||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||||
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/GoogleSheetWrapper";
|
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||||
import {
|
import {
|
||||||
GOOGLE_SHEETS_CLIENT_ID,
|
GOOGLE_SHEETS_CLIENT_ID,
|
||||||
GOOGLE_SHEETS_CLIENT_SECRET,
|
GOOGLE_SHEETS_CLIENT_SECRET,
|
||||||
@@ -40,7 +40,7 @@ const Page = async (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/project/integrations`} />
|
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
|
||||||
<PageHeader pageTitle={t("environments.integrations.google_sheets.google_sheets_integration")} />
|
<PageHeader pageTitle={t("environments.integrations.google_sheets.google_sheets_integration")} />
|
||||||
<div className="h-[75vh] w-full">
|
<div className="h-[75vh] w-full">
|
||||||
<GoogleSheetWrapper
|
<GoogleSheetWrapper
|
||||||
+24
-227
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -15,17 +15,15 @@ import {
|
|||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import {
|
import {
|
||||||
ERRORS,
|
MappingRow,
|
||||||
TYPE_MAPPING,
|
TMapping,
|
||||||
UNSUPPORTED_TYPES_BY_NOTION,
|
createEmptyMapping,
|
||||||
} from "@/app/(app)/environments/[environmentId]/project/integrations/notion/constants";
|
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/MappingRow";
|
||||||
import NotionLogo from "@/images/notion.png";
|
import NotionLogo from "@/images/notion.png";
|
||||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
import { getElementTypes } from "@/modules/survey/lib/elements";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -39,59 +37,6 @@ import {
|
|||||||
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
||||||
import { Label } from "@/modules/ui/components/label";
|
import { Label } from "@/modules/ui/components/label";
|
||||||
|
|
||||||
const MappingErrorMessage = ({
|
|
||||||
error,
|
|
||||||
col,
|
|
||||||
elem,
|
|
||||||
t,
|
|
||||||
}: {
|
|
||||||
error: { type: string; msg?: React.ReactNode | string } | null | undefined;
|
|
||||||
col: { id: string; name: string; type: string };
|
|
||||||
elem: { id: string; name: string; type: string };
|
|
||||||
t: ReturnType<typeof useTranslation>["t"];
|
|
||||||
}) => {
|
|
||||||
const showErrorMsg = useMemo(() => {
|
|
||||||
switch (error?.type) {
|
|
||||||
case ERRORS.UNSUPPORTED_TYPE:
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
-{" "}
|
|
||||||
{t("environments.integrations.notion.col_name_of_type_is_not_supported", {
|
|
||||||
col_name: col.name,
|
|
||||||
type: col.type,
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
case ERRORS.MAPPING:
|
|
||||||
const element = getElementTypes(t).find((et) => et.id === elem.type);
|
|
||||||
if (!element) return null;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{t("environments.integrations.notion.que_name_of_type_cant_be_mapped_to", {
|
|
||||||
que_name: elem.name,
|
|
||||||
question_label: element.label,
|
|
||||||
col_name: col.name,
|
|
||||||
col_type: col.type,
|
|
||||||
mapped_type: TYPE_MAPPING[element.id].join(" ,"),
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [error, col, elem, t]);
|
|
||||||
|
|
||||||
if (!error) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="my-4 w-full rounded-lg bg-red-100 p-4 text-sm text-red-800">
|
|
||||||
<span className="mb-2 block">{error.type}</span>
|
|
||||||
{showErrorMsg}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface AddIntegrationModalProps {
|
interface AddIntegrationModalProps {
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
surveys: TSurvey[];
|
surveys: TSurvey[];
|
||||||
@@ -115,21 +60,7 @@ export const AddIntegrationModal = ({
|
|||||||
const { handleSubmit } = useForm();
|
const { handleSubmit } = useForm();
|
||||||
const [selectedDatabase, setSelectedDatabase] = useState<TIntegrationNotionDatabase | null>();
|
const [selectedDatabase, setSelectedDatabase] = useState<TIntegrationNotionDatabase | null>();
|
||||||
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
|
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
|
||||||
const [mapping, setMapping] = useState<
|
const [mapping, setMapping] = useState<TMapping[]>([createEmptyMapping()]);
|
||||||
{
|
|
||||||
column: { id: string; name: string; type: string };
|
|
||||||
element: { id: string; name: string; type: string };
|
|
||||||
error?: {
|
|
||||||
type: string;
|
|
||||||
msg: React.ReactNode | string;
|
|
||||||
} | null;
|
|
||||||
}[]
|
|
||||||
>([
|
|
||||||
{
|
|
||||||
column: { id: "", name: "", type: "" },
|
|
||||||
element: { id: "", name: "", type: "" },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||||
const [isLinkingDatabase, setIsLinkingDatabase] = useState(false);
|
const [isLinkingDatabase, setIsLinkingDatabase] = useState(false);
|
||||||
const integrationData = {
|
const integrationData = {
|
||||||
@@ -234,7 +165,7 @@ export const AddIntegrationModal = ({
|
|||||||
return survey.id === selectedIntegration.surveyId;
|
return survey.id === selectedIntegration.surveyId;
|
||||||
})!
|
})!
|
||||||
);
|
);
|
||||||
setMapping(selectedIntegration.mapping);
|
setMapping(selectedIntegration.mapping.map((m) => ({ ...m, id: createId() })));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
@@ -320,154 +251,11 @@ export const AddIntegrationModal = ({
|
|||||||
setSelectedDatabase(null);
|
setSelectedDatabase(null);
|
||||||
setSelectedSurvey(null);
|
setSelectedSurvey(null);
|
||||||
};
|
};
|
||||||
const getFilteredElementItems = (selectedIdx) => {
|
const getFilteredElementItems = (selectedIdx: number) => {
|
||||||
const selectedElementIds = mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.element.id);
|
const selectedElementIds = new Set(
|
||||||
|
mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.element.id)
|
||||||
return elementItems.filter((el) => !selectedElementIds.includes(el.id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const createCopy = (item) => structuredClone(item);
|
|
||||||
|
|
||||||
const MappingRow = ({ idx }: { idx: number }) => {
|
|
||||||
const filteredElementItems = getFilteredElementItems(idx);
|
|
||||||
|
|
||||||
const addRow = () => {
|
|
||||||
setMapping((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
column: { id: "", name: "", type: "" },
|
|
||||||
element: { id: "", name: "", type: "" },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteRow = () => {
|
|
||||||
setMapping((prev) => {
|
|
||||||
return prev.filter((_, i) => i !== idx);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFilteredDbItems = () => {
|
|
||||||
const colMapping = mapping.map((m) => m.column.id);
|
|
||||||
return dbItems.filter((item) => !colMapping.includes(item.id));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<MappingErrorMessage
|
|
||||||
key={idx}
|
|
||||||
error={mapping[idx]?.error}
|
|
||||||
col={mapping[idx].column}
|
|
||||||
elem={mapping[idx].element}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
<div className="flex w-full items-center space-x-2">
|
|
||||||
<div className="flex w-full items-center">
|
|
||||||
<div className="max-w-full flex-1">
|
|
||||||
<DropdownSelector
|
|
||||||
placeholder={t("environments.integrations.notion.select_a_survey_question")}
|
|
||||||
items={filteredElementItems}
|
|
||||||
selectedItem={mapping?.[idx]?.element}
|
|
||||||
setSelectedItem={(item) => {
|
|
||||||
setMapping((prev) => {
|
|
||||||
const copy = createCopy(prev);
|
|
||||||
const col = copy[idx].column;
|
|
||||||
if (col.id) {
|
|
||||||
if (UNSUPPORTED_TYPES_BY_NOTION.includes(col.type)) {
|
|
||||||
copy[idx] = {
|
|
||||||
...copy[idx],
|
|
||||||
error: {
|
|
||||||
type: ERRORS.UNSUPPORTED_TYPE,
|
|
||||||
},
|
|
||||||
element: item,
|
|
||||||
};
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValidColType = TYPE_MAPPING[item.type].includes(col.type);
|
|
||||||
if (!isValidColType) {
|
|
||||||
copy[idx] = {
|
|
||||||
...copy[idx],
|
|
||||||
error: {
|
|
||||||
type: ERRORS.MAPPING,
|
|
||||||
},
|
|
||||||
element: item,
|
|
||||||
};
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
copy[idx] = {
|
|
||||||
...copy[idx],
|
|
||||||
element: item,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
return copy;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
disabled={elementItems.length === 0}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="h-px w-4 border-t border-t-slate-300" />
|
|
||||||
<div className="max-w-full flex-1">
|
|
||||||
<DropdownSelector
|
|
||||||
placeholder={t("environments.integrations.notion.select_a_field_to_map")}
|
|
||||||
items={getFilteredDbItems()}
|
|
||||||
selectedItem={mapping?.[idx]?.column}
|
|
||||||
setSelectedItem={(item) => {
|
|
||||||
setMapping((prev) => {
|
|
||||||
const copy = createCopy(prev);
|
|
||||||
const elem = copy[idx].element;
|
|
||||||
if (elem.id) {
|
|
||||||
const isValidElemType = TYPE_MAPPING[elem.type].includes(item.type);
|
|
||||||
|
|
||||||
if (UNSUPPORTED_TYPES_BY_NOTION.includes(item.type)) {
|
|
||||||
copy[idx] = {
|
|
||||||
...copy[idx],
|
|
||||||
error: {
|
|
||||||
type: ERRORS.UNSUPPORTED_TYPE,
|
|
||||||
},
|
|
||||||
column: item,
|
|
||||||
};
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isValidElemType) {
|
|
||||||
copy[idx] = {
|
|
||||||
...copy[idx],
|
|
||||||
error: {
|
|
||||||
type: ERRORS.MAPPING,
|
|
||||||
},
|
|
||||||
column: item,
|
|
||||||
};
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
copy[idx] = {
|
|
||||||
...copy[idx],
|
|
||||||
column: item,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
return copy;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
disabled={dbItems.length === 0}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
{mapping.length > 1 && (
|
|
||||||
<Button variant="secondary" size="icon" className="size-10" onClick={deleteRow}>
|
|
||||||
<TrashIcon />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant="secondary" size="icon" className="size-10" onClick={addRow}>
|
|
||||||
<PlusIcon />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
return elementItems.filter((el) => !selectedElementIds.has(el.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -539,8 +327,17 @@ export const AddIntegrationModal = ({
|
|||||||
{t("environments.integrations.notion.map_formbricks_fields_to_notion_property")}
|
{t("environments.integrations.notion.map_formbricks_fields_to_notion_property")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="mt-1 space-y-2 overflow-y-auto">
|
<div className="mt-1 space-y-2 overflow-y-auto">
|
||||||
{mapping.map((_, idx) => (
|
{mapping.map((m, idx) => (
|
||||||
<MappingRow idx={idx} key={idx} />
|
<MappingRow
|
||||||
|
key={m.id}
|
||||||
|
idx={idx}
|
||||||
|
mapping={mapping}
|
||||||
|
setMapping={setMapping}
|
||||||
|
filteredElementItems={getFilteredElementItems(idx)}
|
||||||
|
dbItems={dbItems}
|
||||||
|
elementItems={elementItems}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
+1
-1
@@ -6,7 +6,7 @@ import toast from "react-hot-toast";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
+228
@@ -0,0 +1,228 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ERRORS,
|
||||||
|
TYPE_MAPPING,
|
||||||
|
UNSUPPORTED_TYPES_BY_NOTION,
|
||||||
|
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/constants";
|
||||||
|
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||||
|
import { getElementTypes } from "@/modules/survey/lib/elements";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
||||||
|
|
||||||
|
const filterByIdx = (targetIdx: number) => (_: unknown, i: number) => i !== targetIdx;
|
||||||
|
|
||||||
|
export type TColumnOrElement = { id: string; name: string; type: string };
|
||||||
|
export type TMappingError = { type: string; msg?: React.ReactNode | string } | null;
|
||||||
|
export type TMapping = {
|
||||||
|
id: string;
|
||||||
|
column: TColumnOrElement;
|
||||||
|
element: TColumnOrElement;
|
||||||
|
error?: TMappingError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createEmptyMapping = (): TMapping => ({
|
||||||
|
id: createId(),
|
||||||
|
column: { id: "", name: "", type: "" },
|
||||||
|
element: { id: "", name: "", type: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const MappingErrorMessage = ({
|
||||||
|
error,
|
||||||
|
col,
|
||||||
|
elem,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
error: TMappingError | undefined;
|
||||||
|
col: TColumnOrElement;
|
||||||
|
elem: TColumnOrElement;
|
||||||
|
t: ReturnType<typeof useTranslation>["t"];
|
||||||
|
}) => {
|
||||||
|
const showErrorMsg = useMemo(() => {
|
||||||
|
switch (error?.type) {
|
||||||
|
case ERRORS.UNSUPPORTED_TYPE: {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
-{" "}
|
||||||
|
{t("environments.integrations.notion.col_name_of_type_is_not_supported", {
|
||||||
|
col_name: col.name,
|
||||||
|
type: col.type,
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case ERRORS.MAPPING: {
|
||||||
|
const element = getElementTypes(t).find((et) => et.id === elem.type);
|
||||||
|
if (!element) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{t("environments.integrations.notion.que_name_of_type_cant_be_mapped_to", {
|
||||||
|
que_name: elem.name,
|
||||||
|
question_label: element.label,
|
||||||
|
col_name: col.name,
|
||||||
|
col_type: col.type,
|
||||||
|
mapped_type: TYPE_MAPPING[element.id].join(" ,"),
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [error, col, elem, t]);
|
||||||
|
|
||||||
|
if (!error) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-4 w-full rounded-lg bg-red-100 p-4 text-sm text-red-800">
|
||||||
|
<span className="mb-2 block">{error.type}</span>
|
||||||
|
{showErrorMsg}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MappingRowProps {
|
||||||
|
idx: number;
|
||||||
|
mapping: TMapping[];
|
||||||
|
setMapping: React.Dispatch<React.SetStateAction<TMapping[]>>;
|
||||||
|
filteredElementItems: TColumnOrElement[];
|
||||||
|
dbItems: TColumnOrElement[];
|
||||||
|
elementItems: TColumnOrElement[];
|
||||||
|
t: ReturnType<typeof useTranslation>["t"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MappingRow = ({
|
||||||
|
idx,
|
||||||
|
mapping,
|
||||||
|
setMapping,
|
||||||
|
filteredElementItems,
|
||||||
|
dbItems,
|
||||||
|
elementItems,
|
||||||
|
t,
|
||||||
|
}: MappingRowProps) => {
|
||||||
|
const createCopy = (items: TMapping[]) => structuredClone(items);
|
||||||
|
|
||||||
|
const addRow = () => {
|
||||||
|
setMapping((prev) => [...prev, createEmptyMapping()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteRow = () => {
|
||||||
|
setMapping((prev) => prev.filter(filterByIdx(idx)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFilteredDbItems = () => {
|
||||||
|
const colMapping = new Set(mapping.map((m) => m.column.id));
|
||||||
|
return dbItems.filter((item) => !colMapping.has(item.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleElementSelect = (item: TColumnOrElement) => {
|
||||||
|
setMapping((prev) => {
|
||||||
|
const copy = createCopy(prev);
|
||||||
|
const col = copy[idx].column;
|
||||||
|
|
||||||
|
if (col.id) {
|
||||||
|
if (UNSUPPORTED_TYPES_BY_NOTION.includes(col.type)) {
|
||||||
|
copy[idx] = {
|
||||||
|
...copy[idx],
|
||||||
|
error: { type: ERRORS.UNSUPPORTED_TYPE },
|
||||||
|
element: item,
|
||||||
|
};
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidColType = TYPE_MAPPING[item.type].includes(col.type);
|
||||||
|
if (!isValidColType) {
|
||||||
|
copy[idx] = {
|
||||||
|
...copy[idx],
|
||||||
|
error: { type: ERRORS.MAPPING },
|
||||||
|
element: item,
|
||||||
|
};
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
copy[idx] = { ...copy[idx], element: item, error: null };
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColumnSelect = (item: TColumnOrElement) => {
|
||||||
|
setMapping((prev) => {
|
||||||
|
const copy = createCopy(prev);
|
||||||
|
const elem = copy[idx].element;
|
||||||
|
|
||||||
|
if (elem.id) {
|
||||||
|
if (UNSUPPORTED_TYPES_BY_NOTION.includes(item.type)) {
|
||||||
|
copy[idx] = {
|
||||||
|
...copy[idx],
|
||||||
|
error: { type: ERRORS.UNSUPPORTED_TYPE },
|
||||||
|
column: item,
|
||||||
|
};
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidElemType = TYPE_MAPPING[elem.type].includes(item.type);
|
||||||
|
if (!isValidElemType) {
|
||||||
|
copy[idx] = {
|
||||||
|
...copy[idx],
|
||||||
|
error: { type: ERRORS.MAPPING },
|
||||||
|
column: item,
|
||||||
|
};
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
copy[idx] = { ...copy[idx], column: item, error: null };
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<MappingErrorMessage
|
||||||
|
error={mapping[idx]?.error}
|
||||||
|
col={mapping[idx].column}
|
||||||
|
elem={mapping[idx].element}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<div className="flex w-full items-center space-x-2">
|
||||||
|
<div className="flex w-full items-center">
|
||||||
|
<div className="max-w-full flex-1">
|
||||||
|
<DropdownSelector
|
||||||
|
placeholder={t("environments.integrations.notion.select_a_survey_question")}
|
||||||
|
items={filteredElementItems}
|
||||||
|
selectedItem={mapping?.[idx]?.element}
|
||||||
|
setSelectedItem={handleElementSelect}
|
||||||
|
disabled={elementItems.length === 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h-px w-4 border-t border-t-slate-300" />
|
||||||
|
<div className="max-w-full flex-1">
|
||||||
|
<DropdownSelector
|
||||||
|
placeholder={t("environments.integrations.notion.select_a_field_to_map")}
|
||||||
|
items={getFilteredDbItems()}
|
||||||
|
selectedItem={mapping?.[idx]?.column}
|
||||||
|
setSelectedItem={handleColumnSelect}
|
||||||
|
disabled={dbItems.length === 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
{mapping.length > 1 && (
|
||||||
|
<Button variant="secondary" size="icon" className="size-10" onClick={deleteRow} type="button">
|
||||||
|
<TrashIcon />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="secondary" size="icon" className="size-10" onClick={addRow} type="button">
|
||||||
|
<PlusIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+2
-2
@@ -9,8 +9,8 @@ import {
|
|||||||
} from "@formbricks/types/integration/notion";
|
} from "@formbricks/types/integration/notion";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/AddIntegrationModal";
|
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/AddIntegrationModal";
|
||||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/ManageIntegration";
|
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/ManageIntegration";
|
||||||
import notionLogo from "@/images/notion.png";
|
import notionLogo from "@/images/notion.png";
|
||||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||||
import { authorize } from "../lib/notion";
|
import { authorize } from "../lib/notion";
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
|
|
||||||
|
export const TYPE_MAPPING = {
|
||||||
|
[TSurveyElementTypeEnum.CTA]: ["checkbox"],
|
||||||
|
[TSurveyElementTypeEnum.MultipleChoiceMulti]: ["multi_select"],
|
||||||
|
[TSurveyElementTypeEnum.MultipleChoiceSingle]: ["select", "status"],
|
||||||
|
[TSurveyElementTypeEnum.OpenText]: [
|
||||||
|
"created_by",
|
||||||
|
"created_time",
|
||||||
|
"email",
|
||||||
|
"last_edited_by",
|
||||||
|
"last_edited_time",
|
||||||
|
"number",
|
||||||
|
"phone_number",
|
||||||
|
"rich_text",
|
||||||
|
"title",
|
||||||
|
"url",
|
||||||
|
],
|
||||||
|
[TSurveyElementTypeEnum.NPS]: ["number"],
|
||||||
|
[TSurveyElementTypeEnum.Consent]: ["checkbox"],
|
||||||
|
[TSurveyElementTypeEnum.Rating]: ["number"],
|
||||||
|
[TSurveyElementTypeEnum.PictureSelection]: ["url"],
|
||||||
|
[TSurveyElementTypeEnum.FileUpload]: ["url"],
|
||||||
|
[TSurveyElementTypeEnum.Date]: ["date"],
|
||||||
|
[TSurveyElementTypeEnum.Address]: ["rich_text"],
|
||||||
|
[TSurveyElementTypeEnum.Matrix]: ["rich_text"],
|
||||||
|
[TSurveyElementTypeEnum.Cal]: ["checkbox"],
|
||||||
|
[TSurveyElementTypeEnum.ContactInfo]: ["rich_text"],
|
||||||
|
[TSurveyElementTypeEnum.Ranking]: ["rich_text"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UNSUPPORTED_TYPES_BY_NOTION = [
|
||||||
|
"rollup",
|
||||||
|
"created_by",
|
||||||
|
"created_time",
|
||||||
|
"last_edited_by",
|
||||||
|
"last_edited_time",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ERRORS = {
|
||||||
|
MAPPING: "Mapping Error",
|
||||||
|
UNSUPPORTED_TYPE: "Unsupported type by Notion",
|
||||||
|
};
|
||||||
+2
-2
@@ -10,7 +10,7 @@ const Loading = () => {
|
|||||||
<div className="mt-6 p-6">
|
<div className="mt-6 p-6">
|
||||||
<GoBackButton />
|
<GoBackButton />
|
||||||
<div className="mb-6 text-right">
|
<div className="mb-6 text-right">
|
||||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
|
||||||
{t("environments.integrations.notion.link_database")}
|
{t("environments.integrations.notion.link_database")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,7 +48,7 @@ const Loading = () => {
|
|||||||
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
|
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
|
||||||
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
|
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center"></div>
|
<div className="text-center"></div>
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
|
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||||
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/NotionWrapper";
|
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper";
|
||||||
import {
|
import {
|
||||||
NOTION_AUTH_URL,
|
NOTION_AUTH_URL,
|
||||||
NOTION_OAUTH_CLIENT_ID,
|
NOTION_OAUTH_CLIENT_ID,
|
||||||
+8
-8
@@ -2,7 +2,7 @@ import { TFunction } from "i18next";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TIntegrationType } from "@formbricks/types/integration";
|
import { TIntegrationType } from "@formbricks/types/integration";
|
||||||
import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/webhook";
|
import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/webhook";
|
||||||
import ActivePiecesLogo from "@/images/activepieces.webp";
|
import ActivePiecesLogo from "@/images/activepieces.webp";
|
||||||
import AirtableLogo from "@/images/airtableLogo.svg";
|
import AirtableLogo from "@/images/airtableLogo.svg";
|
||||||
import GoogleSheetsLogo from "@/images/googleSheetsLogo.png";
|
import GoogleSheetsLogo from "@/images/googleSheetsLogo.png";
|
||||||
@@ -79,7 +79,7 @@ const Page = async (props) => {
|
|||||||
disabled: isReadOnly,
|
disabled: isReadOnly,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
connectHref: `/environments/${params.environmentId}/project/integrations/webhooks`,
|
connectHref: `/environments/${params.environmentId}/workspace/integrations/webhooks`,
|
||||||
connectText: t("environments.integrations.manage_webhooks"),
|
connectText: t("environments.integrations.manage_webhooks"),
|
||||||
connectNewTab: false,
|
connectNewTab: false,
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks",
|
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks",
|
||||||
@@ -93,7 +93,7 @@ const Page = async (props) => {
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
connectHref: `/environments/${params.environmentId}/project/integrations/google-sheets`,
|
connectHref: `/environments/${params.environmentId}/workspace/integrations/google-sheets`,
|
||||||
connectText: `${isGoogleSheetsIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
connectText: `${isGoogleSheetsIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||||
connectNewTab: false,
|
connectNewTab: false,
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/google-sheets",
|
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/google-sheets",
|
||||||
@@ -107,7 +107,7 @@ const Page = async (props) => {
|
|||||||
disabled: isReadOnly,
|
disabled: isReadOnly,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
connectHref: `/environments/${params.environmentId}/project/integrations/airtable`,
|
connectHref: `/environments/${params.environmentId}/workspace/integrations/airtable`,
|
||||||
connectText: `${isAirtableIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
connectText: `${isAirtableIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||||
connectNewTab: false,
|
connectNewTab: false,
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/airtable",
|
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/airtable",
|
||||||
@@ -121,7 +121,7 @@ const Page = async (props) => {
|
|||||||
disabled: isReadOnly,
|
disabled: isReadOnly,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
connectHref: `/environments/${params.environmentId}/project/integrations/slack`,
|
connectHref: `/environments/${params.environmentId}/workspace/integrations/slack`,
|
||||||
connectText: `${isSlackIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
connectText: `${isSlackIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||||
connectNewTab: false,
|
connectNewTab: false,
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/slack",
|
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/slack",
|
||||||
@@ -163,7 +163,7 @@ const Page = async (props) => {
|
|||||||
disabled: isReadOnly,
|
disabled: isReadOnly,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
connectHref: `/environments/${params.environmentId}/project/integrations/notion`,
|
connectHref: `/environments/${params.environmentId}/workspace/integrations/notion`,
|
||||||
connectText: `${isNotionIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
connectText: `${isNotionIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||||
connectNewTab: false,
|
connectNewTab: false,
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/notion",
|
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/notion",
|
||||||
@@ -196,7 +196,7 @@ const Page = async (props) => {
|
|||||||
docsHref: "https://formbricks.com/docs/app-surveys/quickstart",
|
docsHref: "https://formbricks.com/docs/app-surveys/quickstart",
|
||||||
docsText: t("common.docs"),
|
docsText: t("common.docs"),
|
||||||
docsNewTab: true,
|
docsNewTab: true,
|
||||||
connectHref: `/environments/${params.environmentId}/project/app-connection`,
|
connectHref: `/environments/${params.environmentId}/workspace/app-connection`,
|
||||||
connectText: t("common.connect"),
|
connectText: t("common.connect"),
|
||||||
connectNewTab: false,
|
connectNewTab: false,
|
||||||
label: "Javascript SDK",
|
label: "Javascript SDK",
|
||||||
@@ -209,7 +209,7 @@ const Page = async (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<PageHeader pageTitle={t("common.project_configuration")}>
|
<PageHeader pageTitle={t("common.workspace_configuration")}>
|
||||||
<ProjectConfigNavigation environmentId={params.environmentId} activeId="integrations" />
|
<ProjectConfigNavigation environmentId={params.environmentId} activeId="integrations" />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<div className="grid grid-cols-3 place-content-stretch gap-4 lg:grid-cols-3">
|
<div className="grid grid-cols-3 place-content-stretch gap-4 lg:grid-cols-3">
|
||||||
+1
-1
@@ -15,7 +15,7 @@ import {
|
|||||||
} from "@formbricks/types/integration/slack";
|
} from "@formbricks/types/integration/slack";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import SlackLogo from "@/images/slacklogo.png";
|
import SlackLogo from "@/images/slacklogo.png";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
+1
-1
@@ -6,7 +6,7 @@ import toast from "react-hot-toast";
|
|||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
+4
-4
@@ -6,10 +6,10 @@ import { TIntegrationItem } from "@formbricks/types/integration";
|
|||||||
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { getSlackChannelsAction } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/actions";
|
import { getSlackChannelsAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/actions";
|
||||||
import { AddChannelMappingModal } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/components/AddChannelMappingModal";
|
import { AddChannelMappingModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/AddChannelMappingModal";
|
||||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/components/ManageIntegration";
|
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/ManageIntegration";
|
||||||
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/lib/slack";
|
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/lib/slack";
|
||||||
import slackLogo from "@/images/slacklogo.png";
|
import slackLogo from "@/images/slacklogo.png";
|
||||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||||
|
|
||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||||
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/components/SlackWrapper";
|
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper";
|
||||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
||||||
import { getIntegrationByType } from "@/lib/integration/service";
|
import { getIntegrationByType } from "@/lib/integration/service";
|
||||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||||
@@ -32,7 +32,7 @@ const Page = async (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/project/integrations`} />
|
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
|
||||||
<PageHeader pageTitle={t("environments.integrations.slack.slack_integration")} />
|
<PageHeader pageTitle={t("environments.integrations.slack.slack_integration")} />
|
||||||
<div className="h-[75vh] w-full">
|
<div className="h-[75vh] w-full">
|
||||||
<SlackWrapper
|
<SlackWrapper
|
||||||
@@ -64,7 +64,7 @@ export const GET = async (req: Request) => {
|
|||||||
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
|
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
|
||||||
if (result) {
|
if (result) {
|
||||||
return Response.redirect(
|
return Response.redirect(
|
||||||
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/google-sheets`
|
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export const GET = withV1ApiWrapper({
|
|||||||
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
|
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
|
||||||
return {
|
return {
|
||||||
response: Response.redirect(
|
response: Response.redirect(
|
||||||
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/airtable`
|
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/airtable`
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -87,14 +87,14 @@ export const GET = withV1ApiWrapper({
|
|||||||
if (result) {
|
if (result) {
|
||||||
return {
|
return {
|
||||||
response: Response.redirect(
|
response: Response.redirect(
|
||||||
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/notion`
|
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/notion`
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else if (error) {
|
} else if (error) {
|
||||||
return {
|
return {
|
||||||
response: Response.redirect(
|
response: Response.redirect(
|
||||||
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/notion?error=${error}`
|
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/notion?error=${error}`
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,14 +94,14 @@ export const GET = withV1ApiWrapper({
|
|||||||
if (result) {
|
if (result) {
|
||||||
return {
|
return {
|
||||||
response: Response.redirect(
|
response: Response.redirect(
|
||||||
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/slack`
|
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/slack`
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else if (error) {
|
} else if (error) {
|
||||||
return {
|
return {
|
||||||
response: Response.redirect(
|
response: Response.redirect(
|
||||||
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/slack?error=${error}`
|
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/slack?error=${error}`
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4835,7 +4835,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
|||||||
segment: null,
|
segment: null,
|
||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: "cltxxaa6x0000g8hacxdxeje1",
|
||||||
name: "Block 1",
|
name: "Block 1",
|
||||||
elements: [
|
elements: [
|
||||||
{
|
{
|
||||||
@@ -4857,7 +4857,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
|||||||
backButtonLabel: createI18nString(t("templates.preview_survey_question_2_back_button_label"), []),
|
backButtonLabel: createI18nString(t("templates.preview_survey_question_2_back_button_label"), []),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: "cltxxaa6x0000g8hacxdxeje2",
|
||||||
name: "Block 2",
|
name: "Block 2",
|
||||||
elements: [
|
elements: [
|
||||||
{
|
{
|
||||||
@@ -4915,5 +4915,6 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
|||||||
isBackButtonHidden: false,
|
isBackButtonHidden: false,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
questions: [], // Required for build-time type checking (Zod defaults to [] at runtime)
|
questions: [], // Required for build-time type checking (Zod defaults to [] at runtime)
|
||||||
|
slug: null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { LinkSurveyNotFound } from "@/modules/survey/link/not-found";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return <LinkSurveyNotFound />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
|
import { getSurveyBySlug } from "@/modules/survey/lib/slug";
|
||||||
|
|
||||||
|
interface PrettyUrlPageProps {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PrettyUrlPage(props: PrettyUrlPageProps) {
|
||||||
|
const { slug } = await props.params;
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
|
|
||||||
|
if (IS_FORMBRICKS_CLOUD) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const survey = await getSurveyBySlug(slug);
|
||||||
|
if (!survey) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve query params (suId, lang, etc.)
|
||||||
|
const queryString = new URLSearchParams(
|
||||||
|
Object.entries(searchParams).filter(([_, v]) => v !== undefined) as [string, string][]
|
||||||
|
).toString();
|
||||||
|
|
||||||
|
const baseUrl = `/s/${survey.id}`;
|
||||||
|
const redirectUrl = queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
||||||
|
|
||||||
|
redirect(redirectUrl);
|
||||||
|
}
|
||||||
@@ -66,7 +66,7 @@ const Page = async () => {
|
|||||||
|
|
||||||
if (!firstProductionEnvironmentId) {
|
if (!firstProductionEnvironmentId) {
|
||||||
if (isOwner || isManager) {
|
if (isOwner || isManager) {
|
||||||
return redirect(`/organizations/${userOrganizations[0].id}/projects/new/mode`);
|
return redirect(`/organizations/${userOrganizations[0].id}/workspaces/new/mode`);
|
||||||
} else {
|
} else {
|
||||||
return redirect(`/organizations/${userOrganizations[0].id}/landing`);
|
return redirect(`/organizations/${userOrganizations[0].id}/landing`);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user