Compare commits

..

58 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 022fa2f013 Initial plan 2026-01-12 11:49:26 +00:00
Matti Nannt 0a07970779 fix: set i18n language synchronously to fix translation timing issue
The "Required" label in surveys was not being translated because
the language was changed in useEffect, which runs after the first
render. This caused components to render with English (fallback)
before the language was updated.

Now the language is set synchronously before rendering, ensuring
all child components get the correct translations immediately.
2026-01-12 12:43:34 +01:00
Anshuman Pandey 46be3e7d70 feat: webhook secret (#7084) 2026-01-09 12:31:29 +00:00
Dhruwang Jariwala 6d140532a7 feat: add IP address capture functionality to surveys (#7079) 2026-01-09 11:28:05 +00:00
Dhruwang Jariwala 8c4a7f1518 fix: remove subheader field from survey element presets (#7078) 2026-01-09 08:28:48 +00:00
Dhruwang Jariwala 63fe32a786 chore: parallel processing in lingo.dev (#7080) 2026-01-08 05:03:31 +00:00
Matti Nannt 84c465f974 fix: ensure deterministic instanceId via secondary sort key (#7070) 2026-01-07 14:04:56 +00:00
Johannes 6a33498737 feat: Custom HTML scripts in link surveys (#7064)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-01-07 10:06:41 +00:00
Matti Nannt 5130c747d4 chore: license server staging config (#7075)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-01-07 09:50:18 +00:00
Dhruwang Jariwala f5583d2652 fix: add background color to button URL input in CTA element form (#7077) 2026-01-07 09:17:38 +00:00
Fahleen Arif e0d75914a4 fix: update placeholder text for name input field in invite members form (#7054)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-01-07 08:18:36 +00:00
Dhruwang Jariwala f02ca1cfe1 chore: remove string concatenation welcome card (#7073)
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
2026-01-07 07:25:20 +00:00
Anshuman Pandey 4ade83f189 fix: contacts refresh button (#7066) 2026-01-06 12:31:20 +00:00
Jagadish Madavalkar f1fc9fea2c fix: api-wrapper returns valid malformed response (#7053)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-01-06 10:24:39 +00:00
Dhruwang Jariwala 25266e4566 fix: disappearing survey preview (#7065) 2026-01-06 06:23:11 +00:00
Matti Nannt b960cfd2a1 chore: harden CSP and X-Frame-Options headers (#7062)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-01-06 06:21:19 +00:00
Matti Nannt 9e1d1c1dc2 feat: implement robust database seeding strategy (#7017)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-01-05 15:58:58 +00:00
Matti Nannt 8c63a9f7af chore: remove debug log from next.config.mjs (#7063) 2026-01-05 15:52:04 +00:00
Anshuman Pandey fff0a7f052 fix: fixes duplicate userId issue with the contacts UI (#7051)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-01-05 09:21:50 +00:00
Anshuman Pandey 0ecc8aabff fix: fixes single use multi lang surveyUrl issue (#7057) 2026-01-05 06:08:15 +00:00
Dhruwang Jariwala 01cc0ab64d fix: correct typo in recontact waiting time description and adjust da… (#7056) 2026-01-05 06:02:28 +00:00
Anshuman Pandey 1d125bdac2 fix: fixes user api attribute override error (#7050) 2026-01-05 05:55:22 +00:00
Anshuman Pandey ca67c4d5a8 feat: rename projects to workspaces (#7041)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-12-31 07:24:04 +00:00
Dhruwang Jariwala d167d591ce fix: make description optional for consent and CTA elements (#7047) 2025-12-30 10:05:26 +00:00
Anshuman Pandey acc3b0179a fix: defers page view actions to allow user context to be set first (#7048) 2025-12-30 08:56:14 +00:00
Johannes 3434b5cf08 fix: tweak edit attributes for contact UI (#7046)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-12-29 14:58:15 +00:00
Dhruwang Jariwala a618f2df95 fix(types): use z.coerce.date() for ZActionClass timestamps (#7045)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-29 14:47:09 +00:00
Dhruwang Jariwala 5b334f6623 feat: UI to change attribute value for contacts (#7040) 2025-12-29 13:09:29 +00:00
Anshuman Pandey fa2b63d6a1 feat: custom favicon (#7044) 2025-12-29 12:44:32 +00:00
Dhruwang Jariwala 9f0fe69b6b fix: typos (Duplicate of 7042) (#7043)
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
2025-12-29 06:19:54 +00:00
Dhruwang Jariwala 98cb2de02b feat: UI to manage attribute keys (#7038) 2025-12-26 10:02:37 +00:00
Anshuman Pandey f00d0b7e20 fix: setUserId lets users override the previous userId (#7035) 2025-12-25 07:10:56 +00:00
Johannes 65abd4ee07 feat: add pretty URL UI components for surveys (#6969)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-12-24 06:39:46 +00:00
Johannes 939f135bf4 chore: unify error state for all questions types (#7001)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-12-24 06:36:48 +00:00
Johannes 729a16854a fix: German translations (#7033)
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
2025-12-24 06:36:21 +00:00
Dhruwang Jariwala a2d3e37d69 fix: CSS variable pollution (#7026) 2025-12-24 05:54:52 +00:00
Dhruwang Jariwala adf12f551d fix: Swedish translations (#7032) 2025-12-23 12:02:26 +00:00
Dhruwang Jariwala 3f2bddc358 feat: Russian translations (#7027) 2025-12-23 10:31:09 +00:00
Dhruwang Jariwala ae6d1ac133 chore: improve wording in email text (Duplicate of #7003) (#7025)
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
Co-authored-by: Johannes <johannes@formbricks.com>
2025-12-23 09:56:53 +00:00
Dhruwang Jariwala 7c4569cd50 fix: file upload validation (#7028) 2025-12-23 09:36:45 +00:00
Matti Nannt 7354122447 fix: update V2 API OpenAPI paths to include full prefixes (#6983)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-23 06:29:25 +00:00
Matti Nannt d54dca2b27 docs: update thanks section with chromatic and sentry logos (#7031) 2025-12-22 16:40:39 +00:00
Anshuman Pandey acd5cff534 feat: email package for client side email components (#6986) 2025-12-22 14:13:06 +00:00
Matti Nannt 834929e766 feat: configure @formbricks/survey-ui for external publishing (#6991) 2025-12-22 12:39:54 +00:00
Dhruwang Jariwala 09f40ad816 fix: required cta issue (#7022) 2025-12-22 08:35:08 +00:00
Harsh Bhat 689b6491b3 docs: Link vs In app surveys (#7006)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-12-22 08:13:45 +00:00
Johannes b70b2eef95 fix: vimeo + loom embed (#7018)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-12-20 08:08:48 +00:00
Harsh Bhat 392a95834b docs: Best practices Panel Management (#7011) 2025-12-20 06:32:57 +00:00
Anshuman Pandey 66d9cc8eac chore: adds docs for min browser version support (#7014) 2025-12-19 10:02:01 +00:00
Johannes befdc078f1 fix: replace isomorphic-dompurify with sanitize-html in server component (#7002) 2025-12-19 07:34:56 +00:00
Dhruwang Jariwala 13b983b3b2 fix: missing question media (#6997)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-19 07:29:06 +00:00
Harsh Bhat 1e285ebe4e docs: Remove references of delay removal with debug mode (#7009) 2025-12-19 07:03:02 +00:00
Dhruwang Jariwala a7c4971952 fix: replaced bg-white with survey-bg color in surveys package (#7004)
Co-authored-by: Luis Gustavo S. Barreto <gustavo@ossystems.com.br>
2025-12-19 06:50:33 +00:00
Dhruwang Jariwala c8689d91d5 fix: empty button in cta question (#6995) 2025-12-18 21:18:48 +00:00
Dhruwang Jariwala 73a2ff7421 fix: border radius for inputs (#6996) 2025-12-18 20:56:47 +00:00
Dhruwang Jariwala 0c28e89b41 fix: missing required question warning (#6998) 2025-12-18 19:12:47 +00:00
Anshuman Pandey a736436e29 chore: fixes typo (#6993) 2025-12-18 09:25:12 +00:00
Johannes 7dbb0300d3 fix: Pass the isExternalUrlAllowed prop to welcome card (#6992) 2025-12-18 08:51:21 +00:00
449 changed files with 21923 additions and 7737 deletions
+3
View File
@@ -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)
+24 -7
View File
@@ -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
+8
View File
@@ -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>
&nbsp;&nbsp;&nbsp;&nbsp;
<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>
@@ -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={"/"}>
@@ -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`);
} }
@@ -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={"/"}>
@@ -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>
@@ -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} />
@@ -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>
@@ -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
@@ -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) {
@@ -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>
@@ -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"),
@@ -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>
);
};
@@ -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;
@@ -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) {
@@ -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>
@@ -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} />
@@ -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") {
@@ -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");
@@ -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>
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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")}
@@ -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);
@@ -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 {
@@ -21,6 +21,7 @@ import {
ListOrderedIcon, ListOrderedIcon,
MessageSquareTextIcon, MessageSquareTextIcon,
MousePointerClickIcon, MousePointerClickIcon,
NetworkIcon,
PieChartIcon, PieChartIcon,
Rows3Icon, Rows3Icon,
SmartphoneIcon, SmartphoneIcon,
@@ -99,6 +100,7 @@ const elementIcons = {
action: MousePointerClickIcon, action: MousePointerClickIcon,
country: FlagIcon, country: FlagIcon,
url: LinkIcon, url: LinkIcon,
ipAddress: NetworkIcon,
// others // others
Language: LanguagesIcon, Language: LanguagesIcon,
@@ -190,7 +192,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
value={inputValue} value={inputValue}
onValueChange={setInputValue} onValueChange={setInputValue}
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")} placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0" className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none ring-offset-transparent outline-none focus:border-none focus:shadow-none focus:ring-offset-0 focus:outline-none"
/> />
)} )}
<Button <Button
@@ -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"])}
@@ -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";
@@ -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";
@@ -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
@@ -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">
@@ -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";
@@ -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";
@@ -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>
@@ -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
@@ -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>
@@ -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";
@@ -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>
);
};
@@ -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";
@@ -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",
};
@@ -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>
@@ -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,
@@ -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">
@@ -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";
@@ -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";
@@ -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";
@@ -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
@@ -82,6 +82,7 @@ const mockPipelineInput = {
}, },
country: "USA", country: "USA",
action: "Action Name", action: "Action Name",
ipAddress: "203.0.113.7",
} as TResponseMeta, } as TResponseMeta,
personAttributes: {}, personAttributes: {},
singleUseId: null, singleUseId: null,
@@ -346,7 +347,7 @@ describe("handleIntegrations", () => {
expect(airtableWriteData).toHaveBeenCalledTimes(1); expect(airtableWriteData).toHaveBeenCalledTimes(1);
// Adjust expectations for metadata and recalled question // Adjust expectations for metadata and recalled question
const expectedMetadataString = const expectedMetadataString =
"Source: web\nURL: http://example.com\nBrowser: Chrome\nOS: Mac OS\nDevice: Desktop\nCountry: USA\nAction: Action Name"; "Source: web\nURL: http://example.com\nBrowser: Chrome\nOS: Mac OS\nDevice: Desktop\nCountry: USA\nAction: Action Name\nIP Address: 203.0.113.7";
expect(airtableWriteData).toHaveBeenCalledWith( expect(airtableWriteData).toHaveBeenCalledWith(
mockAirtableIntegration.config.key, mockAirtableIntegration.config.key,
mockAirtableIntegration.config.data[0], mockAirtableIntegration.config.data[0],
@@ -31,6 +31,7 @@ const convertMetaObjectToString = (metadata: TResponseMeta): string => {
if (metadata.userAgent?.device) result.push(`Device: ${metadata.userAgent.device}`); if (metadata.userAgent?.device) result.push(`Device: ${metadata.userAgent.device}`);
if (metadata.country) result.push(`Country: ${metadata.country}`); if (metadata.country) result.push(`Country: ${metadata.country}`);
if (metadata.action) result.push(`Action: ${metadata.action}`); if (metadata.action) result.push(`Action: ${metadata.action}`);
if (metadata.ipAddress) result.push(`IP Address: ${metadata.ipAddress}`);
// Join all the elements in the result array with a newline for formatting // Join all the elements in the result array with a newline for formatting
return result.join("\n"); return result.join("\n");
+43 -19
View File
@@ -1,5 +1,6 @@
import { PipelineTriggers, Webhook } from "@prisma/client"; import { PipelineTriggers, Webhook } from "@prisma/client";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { v7 as uuidv7 } from "uuid";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ResourceNotFoundError } from "@formbricks/types/errors";
@@ -8,6 +9,7 @@ import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { CRON_SECRET } from "@/lib/constants"; import { CRON_SECRET } from "@/lib/constants";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { getIntegrations } from "@/lib/integration/service"; import { getIntegrations } from "@/lib/integration/service";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseCountBySurveyId } from "@/lib/response/service"; import { getResponseCountBySurveyId } from "@/lib/response/service";
@@ -90,28 +92,50 @@ export const POST = async (request: Request) => {
]); ]);
}; };
const webhookPromises = webhooks.map((webhook) => const webhookPromises = webhooks.map((webhook) => {
fetchWithTimeout(webhook.url, { const body = JSON.stringify({
method: "POST", webhookId: webhook.id,
headers: { "content-type": "application/json" }, event,
body: JSON.stringify({ data: {
webhookId: webhook.id, ...response,
event, survey: {
data: { title: survey.name,
...response, type: survey.type,
survey: { status: survey.status,
title: survey.name, createdAt: survey.createdAt,
type: survey.type, updatedAt: survey.updatedAt,
status: survey.status,
createdAt: survey.createdAt,
updatedAt: survey.updatedAt,
},
}, },
}), },
});
// Generate Standard Webhooks headers
const webhookMessageId = uuidv7();
const webhookTimestamp = Math.floor(Date.now() / 1000);
const requestHeaders: Record<string, string> = {
"content-type": "application/json",
"webhook-id": webhookMessageId,
"webhook-timestamp": webhookTimestamp.toString(),
};
// Add signature if webhook has a secret configured
if (webhook.secret) {
requestHeaders["webhook-signature"] = generateStandardWebhookSignature(
webhookMessageId,
webhookTimestamp,
body,
webhook.secret
);
}
return fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
}).catch((error) => { }).catch((error) => {
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`); logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
}) });
); });
if (event === "responseFinished") { if (event === "responseFinished") {
// Fetch integrations and responseCount in parallel // Fetch integrations and responseCount in parallel
@@ -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`
); );
} }
@@ -11,6 +11,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines"; import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers"; import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils"; import { validateFileUploads } from "@/modules/storage/utils";
@@ -136,6 +137,13 @@ export const POST = withV1ApiWrapper({
action: responseInputData?.meta?.action, action: responseInputData?.meta?.action,
}; };
// Capture IP address if the survey has IP capture enabled
// Server-derived IP always overwrites any client-provided value
if (survey.isCaptureIpEnabled) {
const ipAddress = await getClientIpFromHeaders();
meta.ipAddress = ipAddress;
}
response = await createResponseWithQuotaEvaluation({ response = await createResponseWithQuotaEvaluation({
...responseInputData, ...responseInputData,
meta, meta,
@@ -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}`
), ),
}; };
} }

Some files were not shown because too many files have changed in this diff Show More