mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-31 12:23:33 -06:00
Compare commits
71 Commits
4.4.0
...
chore/seo-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea486a7e2f | ||
|
|
d474a94a21 | ||
|
|
c1a4cc308b | ||
|
|
210da98b69 | ||
|
|
2fc183d384 | ||
|
|
78fb111610 | ||
|
|
11c0cb4b61 | ||
|
|
95831f7c7f | ||
|
|
a31e7bfaa5 | ||
|
|
6e35fc1769 | ||
|
|
48cded1646 | ||
|
|
db752cee15 | ||
|
|
b33aae0a73 | ||
|
|
72126ad736 | ||
|
|
4a2eeac90b | ||
|
|
46be3e7d70 | ||
|
|
6d140532a7 | ||
|
|
8c4a7f1518 | ||
|
|
63fe32a786 | ||
|
|
84c465f974 | ||
|
|
6a33498737 | ||
|
|
5130c747d4 | ||
|
|
f5583d2652 | ||
|
|
e0d75914a4 | ||
|
|
f02ca1cfe1 | ||
|
|
4ade83f189 | ||
|
|
f1fc9fea2c | ||
|
|
25266e4566 | ||
|
|
b960cfd2a1 | ||
|
|
9e1d1c1dc2 | ||
|
|
8c63a9f7af | ||
|
|
fff0a7f052 | ||
|
|
0ecc8aabff | ||
|
|
01cc0ab64d | ||
|
|
1d125bdac2 | ||
|
|
ca67c4d5a8 | ||
|
|
d167d591ce | ||
|
|
acc3b0179a | ||
|
|
3434b5cf08 | ||
|
|
a618f2df95 | ||
|
|
5b334f6623 | ||
|
|
fa2b63d6a1 | ||
|
|
9f0fe69b6b | ||
|
|
98cb2de02b | ||
|
|
f00d0b7e20 | ||
|
|
65abd4ee07 | ||
|
|
939f135bf4 | ||
|
|
729a16854a | ||
|
|
a2d3e37d69 | ||
|
|
adf12f551d | ||
|
|
3f2bddc358 | ||
|
|
ae6d1ac133 | ||
|
|
7c4569cd50 | ||
|
|
7354122447 | ||
|
|
d54dca2b27 | ||
|
|
acd5cff534 | ||
|
|
834929e766 | ||
|
|
09f40ad816 | ||
|
|
689b6491b3 | ||
|
|
b70b2eef95 | ||
|
|
392a95834b | ||
|
|
66d9cc8eac | ||
|
|
befdc078f1 | ||
|
|
13b983b3b2 | ||
|
|
1e285ebe4e | ||
|
|
a7c4971952 | ||
|
|
c8689d91d5 | ||
|
|
73a2ff7421 | ||
|
|
0c28e89b41 | ||
|
|
a736436e29 | ||
|
|
7dbb0300d3 |
@@ -168,6 +168,9 @@ SLACK_CLIENT_SECRET=
|
||||
# Enterprise License Key
|
||||
ENTERPRISE_LICENSE_KEY=
|
||||
|
||||
# Internal Environment (production, staging) - used for internal staging environment
|
||||
# ENVIRONMENT=production
|
||||
|
||||
# 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)
|
||||
# (Role Management is an Enterprise feature)
|
||||
|
||||
31
.github/workflows/chromatic.yml
vendored
31
.github/workflows/chromatic.yml
vendored
@@ -13,13 +13,12 @@ jobs:
|
||||
chromatic:
|
||||
name: Run Chromatic
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
packages: write
|
||||
id-token: write
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- 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:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -27,16 +26,34 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install pnpm
|
||||
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
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: Run Chromatic
|
||||
uses: chromaui/action@c93e0bc3a63aa176e14a75b61a31847cbfdd341c # latest
|
||||
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
|
||||
with:
|
||||
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
|
||||
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||
workingDir: apps/storybook
|
||||
zip: true
|
||||
|
||||
36
.github/workflows/pr-size-check.yml
vendored
36
.github/workflows/pr-size-check.yml
vendored
@@ -111,27 +111,21 @@ jobs:
|
||||
const additions = ${{ steps.check-size.outputs.total_additions }};
|
||||
const deletions = ${{ steps.check-size.outputs.total_deletions }};
|
||||
|
||||
const body = `## 🚨 PR Size Warning
|
||||
|
||||
This PR has approximately **${totalChanges} lines** of changes (${additions} additions, ${deletions} deletions across ${countedFiles} files).
|
||||
|
||||
Large PRs (>800 lines) are significantly harder to review and increase the chance of merge conflicts. Consider splitting this into smaller, self-contained PRs.
|
||||
|
||||
### 💡 Suggestions:
|
||||
- **Split by feature or module** - Break down into logical, independent pieces
|
||||
- **Create a sequence of PRs** - Each building on the previous one
|
||||
- **Branch off PR branches** - Don't wait for reviews to continue dependent work
|
||||
|
||||
### 📊 What was counted:
|
||||
- ✅ Source files, stylesheets, configuration files
|
||||
- ❌ Excluded ${excludedFiles} files (tests, locales, locks, generated files)
|
||||
|
||||
### 📚 Guidelines:
|
||||
- **Ideal:** 300-500 lines per PR
|
||||
- **Warning:** 500-800 lines
|
||||
- **Critical:** 800+ lines ⚠️
|
||||
|
||||
If this large PR is unavoidable (e.g., migration, dependency update, major refactor), please explain in the PR description why it couldn't be split.`;
|
||||
const body = '## 🚨 PR Size Warning\n\n' +
|
||||
'This PR has approximately **' + totalChanges + ' lines** of changes (' + additions + ' additions, ' + deletions + ' deletions across ' + countedFiles + ' files).\n\n' +
|
||||
'Large PRs (>800 lines) are significantly harder to review and increase the chance of merge conflicts. Consider splitting this into smaller, self-contained PRs.\n\n' +
|
||||
'### 💡 Suggestions:\n' +
|
||||
'- **Split by feature or module** - Break down into logical, independent pieces\n' +
|
||||
'- **Create a sequence of PRs** - Each building on the previous one\n' +
|
||||
'- **Branch off PR branches** - Don\'t wait for reviews to continue dependent work\n\n' +
|
||||
'### 📊 What was counted:\n' +
|
||||
'- ✅ Source files, stylesheets, configuration files\n' +
|
||||
'- ❌ Excluded ' + excludedFiles + ' files (tests, locales, locks, generated files)\n\n' +
|
||||
'### 📚 Guidelines:\n' +
|
||||
'- **Ideal:** 300-500 lines per PR\n' +
|
||||
'- **Warning:** 500-800 lines\n' +
|
||||
'- **Critical:** 800+ lines ⚠️\n\n' +
|
||||
'If this large PR is unavoidable (e.g., migration, dependency update, major refactor), please explain in the PR description why it couldn\'t be split.';
|
||||
|
||||
// Check if we already commented
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -62,3 +62,4 @@ branch.json
|
||||
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
|
||||
.cursorrules
|
||||
i18n.cache
|
||||
stats.html
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
# Load environment variables from .env files
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
|
||||
@@ -203,6 +203,14 @@ Here are a few options:
|
||||
|
||||
</a>
|
||||
|
||||
## Thanks
|
||||
|
||||
Formbricks is supported by the following companies who provide us with their tools for free as part of their open-source support:
|
||||
|
||||
<a href="https://www.chromatic.com/"><img src="https://user-images.githubusercontent.com/321738/84662277-e3db4f80-af1b-11ea-88f5-91d67a5e59f6.png" width="153" height="30" alt="Chromatic" /></a>
|
||||
|
||||
<a href="https://sentry.io/"><img src="https://github.com/user-attachments/assets/d743ffd4-b575-4802-a29a-10136be9227e" width="150" height="30" alt="Sentry" /></a>
|
||||
|
||||
<a id="contact-us"></a>
|
||||
|
||||
## 📆 Contact us
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"@tailwindcss/vite": "4.1.17",
|
||||
"@typescript-eslint/parser": "8.48.0",
|
||||
"@vitejs/plugin-react": "5.1.1",
|
||||
"esbuild": "0.27.0",
|
||||
"esbuild": "0.27.1",
|
||||
"eslint-plugin-storybook": "10.0.8",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "10.0.8",
|
||||
|
||||
7
apps/web/.eslintignore
Normal file
7
apps/web/.eslintignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.next/
|
||||
public/
|
||||
playwright/
|
||||
dist/
|
||||
coverage/
|
||||
vendor/
|
||||
@@ -104,6 +104,9 @@ RUN chown -R nextjs:nextjs ./apps/web/.next/static && chmod -R 755 ./apps/web/.n
|
||||
COPY --from=installer /app/apps/web/public ./apps/web/public
|
||||
RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
|
||||
|
||||
# Create packages/database directory structure with proper ownership for runtime migrations
|
||||
RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database
|
||||
|
||||
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
||||
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const Page = async (props: ConnectPageProps) => {
|
||||
|
||||
const project = await getProjectByEnvironmentId(environment.id);
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
}
|
||||
|
||||
const channel = project.config.channel || null;
|
||||
@@ -46,7 +46,7 @@ const Page = async (props: ConnectPageProps) => {
|
||||
channel={channel}
|
||||
/>
|
||||
<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"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}`}>
|
||||
|
||||
@@ -38,7 +38,7 @@ const Page = async (props: XMTemplatePageProps) => {
|
||||
|
||||
const project = await getProjectByEnvironmentId(environment.id);
|
||||
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);
|
||||
@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
|
||||
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
||||
{projects.length >= 2 && (
|
||||
<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"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}/surveys`}>
|
||||
|
||||
@@ -50,8 +50,8 @@ const Page = async (props) => {
|
||||
</div>
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
||||
<Header
|
||||
title={t("organizations.landing.no_projects_warning_title")}
|
||||
subtitle={t("organizations.landing.no_projects_warning_subtitle")}
|
||||
title={t("organizations.landing.no_workspaces_warning_title")}
|
||||
subtitle={t("organizations.landing.no_workspaces_warning_subtitle")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,16 +26,16 @@ const Page = async (props: ChannelPageProps) => {
|
||||
const t = await getTranslate();
|
||||
const channelOptions = [
|
||||
{
|
||||
title: t("organizations.projects.new.channel.link_and_email_surveys"),
|
||||
description: t("organizations.projects.new.channel.link_and_email_surveys_description"),
|
||||
title: t("organizations.workspaces.new.channel.link_and_email_surveys"),
|
||||
description: t("organizations.workspaces.new.channel.link_and_email_surveys_description"),
|
||||
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"),
|
||||
description: t("organizations.projects.new.channel.in_product_surveys_description"),
|
||||
title: t("organizations.workspaces.new.channel.in_product_surveys"),
|
||||
description: t("organizations.workspaces.new.channel.in_product_surveys_description"),
|
||||
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 (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header
|
||||
title={t("organizations.projects.new.channel.channel_select_title")}
|
||||
subtitle={t("organizations.projects.new.channel.channel_select_subtitle")}
|
||||
title={t("organizations.workspaces.new.channel.channel_select_title")}
|
||||
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
|
||||
/>
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{projects.length >= 1 && (
|
||||
<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"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
@@ -15,7 +15,7 @@ const OnboardingLayout = async (props) => {
|
||||
const t = await getTranslate();
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || !session.user) {
|
||||
if (!session?.user) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
@@ -26,16 +26,16 @@ const Page = async (props: ModePageProps) => {
|
||||
const t = await getTranslate();
|
||||
const channelOptions = [
|
||||
{
|
||||
title: t("organizations.projects.new.mode.formbricks_surveys"),
|
||||
description: t("organizations.projects.new.mode.formbricks_surveys_description"),
|
||||
title: t("organizations.workspaces.new.mode.formbricks_surveys"),
|
||||
description: t("organizations.workspaces.new.mode.formbricks_surveys_description"),
|
||||
icon: ListTodoIcon,
|
||||
href: `/organizations/${params.organizationId}/projects/new/channel`,
|
||||
href: `/organizations/${params.organizationId}/workspaces/new/channel`,
|
||||
},
|
||||
{
|
||||
title: t("organizations.projects.new.mode.formbricks_cx"),
|
||||
description: t("organizations.projects.new.mode.formbricks_cx_description"),
|
||||
title: t("organizations.workspaces.new.mode.formbricks_cx"),
|
||||
description: t("organizations.workspaces.new.mode.formbricks_cx_description"),
|
||||
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 (
|
||||
<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} />
|
||||
{projects.length >= 1 && (
|
||||
<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"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
@@ -79,7 +79,7 @@ export const ProjectSettings = ({
|
||||
(environment) => environment.type === "production"
|
||||
);
|
||||
if (productionEnvironment) {
|
||||
if (typeof window !== "undefined") {
|
||||
if (globalThis.window !== undefined) {
|
||||
// Rmove filters when creating a new project
|
||||
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export const ProjectSettings = ({
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
};
|
||||
@@ -107,7 +107,6 @@ export const ProjectSettings = ({
|
||||
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
|
||||
teamIds: [],
|
||||
},
|
||||
|
||||
resolver: zodResolver(ZProjectUpdateInput),
|
||||
});
|
||||
const projectName = form.watch("name");
|
||||
@@ -131,9 +130,9 @@ export const ProjectSettings = ({
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full space-y-4">
|
||||
<div>
|
||||
<FormLabel>{t("organizations.projects.new.settings.brand_color")}</FormLabel>
|
||||
<FormLabel>{t("organizations.workspaces.new.settings.brand_color")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("organizations.projects.new.settings.brand_color_description")}
|
||||
{t("organizations.workspaces.new.settings.brand_color_description")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
@@ -155,9 +154,9 @@ export const ProjectSettings = ({
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full space-y-4">
|
||||
<div>
|
||||
<FormLabel>{t("organizations.projects.new.settings.project_name")}</FormLabel>
|
||||
<FormLabel>{t("organizations.workspaces.new.settings.workspace_name")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("organizations.projects.new.settings.project_name_description")}
|
||||
{t("organizations.workspaces.new.settings.workspace_name_description")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
@@ -186,7 +185,7 @@ export const ProjectSettings = ({
|
||||
<div>
|
||||
<FormLabel>{t("common.teams")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("organizations.projects.new.settings.team_description")}
|
||||
{t("organizations.workspaces.new.settings.team_description")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<Button
|
||||
@@ -194,7 +193,7 @@ export const ProjectSettings = ({
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => setCreateTeamModalOpen(true)}>
|
||||
{t("organizations.projects.new.settings.create_new_team")}
|
||||
{t("organizations.workspaces.new.settings.create_new_team")}
|
||||
</Button>
|
||||
</div>
|
||||
<FormControl>
|
||||
@@ -227,7 +226,7 @@ export const ProjectSettings = ({
|
||||
alt="Logo"
|
||||
width={256}
|
||||
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>
|
||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
||||
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 { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
@@ -53,8 +53,8 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header
|
||||
title={t("organizations.projects.new.settings.project_settings_title")}
|
||||
subtitle={t("organizations.projects.new.settings.project_settings_subtitle")}
|
||||
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
|
||||
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
|
||||
/>
|
||||
<ProjectSettings
|
||||
organizationId={params.organizationId}
|
||||
@@ -69,7 +69,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
/>
|
||||
{projects.length >= 1 && (
|
||||
<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"
|
||||
asChild>
|
||||
<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);
|
||||
|
||||
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) {
|
||||
|
||||
@@ -43,7 +43,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
|
||||
// Validate that project permission exists for members
|
||||
if (isMember && !projectPermission) {
|
||||
throw new Error(t("common.project_permission_not_found"));
|
||||
throw new Error(t("common.workspace_permission_not_found"));
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -113,7 +113,7 @@ export const MainNavigation = ({
|
||||
},
|
||||
{
|
||||
name: t("common.configuration"),
|
||||
href: `/environments/${environment.id}/project/general`,
|
||||
href: `/environments/${environment.id}/workspace/general`,
|
||||
icon: Cog,
|
||||
isActive: pathname?.includes("/project"),
|
||||
},
|
||||
@@ -164,7 +164,7 @@ export const MainNavigation = ({
|
||||
<aside
|
||||
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",
|
||||
!isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded"
|
||||
isCollapsed ? "w-sidebar-expanded" : "w-sidebar-collapsed"
|
||||
)}>
|
||||
<div>
|
||||
{/* Logo and Toggle */}
|
||||
@@ -185,7 +185,7 @@ export const MainNavigation = ({
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
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 ? (
|
||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||
|
||||
@@ -17,13 +17,13 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
|
||||
const stati = {
|
||||
notImplemented: {
|
||||
icon: AlertTriangleIcon,
|
||||
title: t("environments.project.app-connection.formbricks_sdk_not_connected"),
|
||||
subtitle: t("environments.project.app-connection.formbricks_sdk_not_connected_description"),
|
||||
title: t("environments.workspace.app-connection.formbricks_sdk_not_connected"),
|
||||
subtitle: t("environments.workspace.app-connection.formbricks_sdk_not_connected_description"),
|
||||
},
|
||||
running: {
|
||||
icon: CheckIcon,
|
||||
title: t("environments.project.app-connection.receiving_data"),
|
||||
subtitle: t("environments.project.app-connection.formbricks_sdk_connected"),
|
||||
title: t("environments.workspace.app-connection.receiving_data"),
|
||||
subtitle: t("environments.workspace.app-connection.formbricks_sdk_connected"),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -53,11 +53,11 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
|
||||
<currentStatus.icon />
|
||||
</div>
|
||||
<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" && (
|
||||
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
||||
<RotateCcwIcon />
|
||||
{t("environments.project.app-connection.recheck")}
|
||||
{t("environments.workspace.app-connection.recheck")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import {
|
||||
BuildingIcon,
|
||||
Building2Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
Loader2,
|
||||
@@ -144,6 +144,12 @@ export const OrganizationBreadcrumb = ({
|
||||
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
|
||||
hidden: !isOwnerOrManager,
|
||||
},
|
||||
{
|
||||
id: "domain",
|
||||
label: t("common.domain"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/domain`,
|
||||
hidden: isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
label: t("common.billing"),
|
||||
@@ -166,7 +172,7 @@ export const OrganizationBreadcrumb = ({
|
||||
id="organizationDropdownTrigger"
|
||||
asChild>
|
||||
<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>
|
||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{isOrganizationDropdownOpen ? (
|
||||
@@ -180,7 +186,7 @@ export const OrganizationBreadcrumb = ({
|
||||
{showOrganizationDropdown && (
|
||||
<>
|
||||
<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")}
|
||||
</div>
|
||||
{isLoadingOrganizations && (
|
||||
@@ -203,7 +209,7 @@ export const OrganizationBreadcrumb = ({
|
||||
)}
|
||||
{!isLoadingOrganizations && !loadError && (
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||
{organizations.map((org) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={org.id}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { useEffect, useState, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -36,12 +36,12 @@ interface ProjectBreadcrumbProps {
|
||||
}
|
||||
|
||||
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/")) {
|
||||
return false;
|
||||
}
|
||||
// Check if path matches /project/{settingId} (with optional trailing path)
|
||||
const pattern = new RegExp(`/project/${settingId}(?:/|$)`);
|
||||
// Check if path matches /workspace/{settingId} (with optional trailing path)
|
||||
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
|
||||
return pattern.test(pathname);
|
||||
};
|
||||
|
||||
@@ -90,7 +90,7 @@ export const ProjectBreadcrumb = ({
|
||||
const error = new Error(errorMessage);
|
||||
logger.error(error, "Failed to load projects");
|
||||
Sentry.captureException(error);
|
||||
setLoadError(errorMessage || t("common.failed_to_load_projects"));
|
||||
setLoadError(errorMessage || t("common.failed_to_load_workspaces"));
|
||||
}
|
||||
setIsLoadingProjects(false);
|
||||
});
|
||||
@@ -101,42 +101,42 @@ export const ProjectBreadcrumb = ({
|
||||
{
|
||||
id: "general",
|
||||
label: t("common.general"),
|
||||
href: `/environments/${currentEnvironmentId}/project/general`,
|
||||
href: `/environments/${currentEnvironmentId}/workspace/general`,
|
||||
},
|
||||
{
|
||||
id: "look",
|
||||
label: t("common.look_and_feel"),
|
||||
href: `/environments/${currentEnvironmentId}/project/look`,
|
||||
href: `/environments/${currentEnvironmentId}/workspace/look`,
|
||||
},
|
||||
{
|
||||
id: "app-connection",
|
||||
label: t("common.website_and_app_connection"),
|
||||
href: `/environments/${currentEnvironmentId}/project/app-connection`,
|
||||
href: `/environments/${currentEnvironmentId}/workspace/app-connection`,
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: t("common.integrations"),
|
||||
href: `/environments/${currentEnvironmentId}/project/integrations`,
|
||||
href: `/environments/${currentEnvironmentId}/workspace/integrations`,
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
label: t("common.team_access"),
|
||||
href: `/environments/${currentEnvironmentId}/project/teams`,
|
||||
href: `/environments/${currentEnvironmentId}/workspace/teams`,
|
||||
},
|
||||
{
|
||||
id: "languages",
|
||||
label: t("common.survey_languages"),
|
||||
href: `/environments/${currentEnvironmentId}/project/languages`,
|
||||
href: `/environments/${currentEnvironmentId}/workspace/languages`,
|
||||
},
|
||||
{
|
||||
id: "tags",
|
||||
label: t("common.tags"),
|
||||
href: `/environments/${currentEnvironmentId}/project/tags`,
|
||||
href: `/environments/${currentEnvironmentId}/workspace/tags`,
|
||||
},
|
||||
];
|
||||
|
||||
if (!currentProject) {
|
||||
const errorMessage = `Project not found for project id: ${currentProjectId}`;
|
||||
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
|
||||
logger.error(errorMessage);
|
||||
Sentry.captureException(new Error(errorMessage));
|
||||
return;
|
||||
@@ -145,7 +145,7 @@ export const ProjectBreadcrumb = ({
|
||||
const handleProjectChange = (projectId: string) => {
|
||||
if (projectId === currentProjectId) return;
|
||||
startTransition(() => {
|
||||
router.push(`/projects/${projectId}/`);
|
||||
router.push(`/workspaces/${projectId}/`);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -159,7 +159,7 @@ export const ProjectBreadcrumb = ({
|
||||
|
||||
const handleProjectSettingsNavigation = (settingId: string) => {
|
||||
startTransition(() => {
|
||||
router.push(`/environments/${currentEnvironmentId}/project/${settingId}`);
|
||||
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -198,21 +198,21 @@ export const ProjectBreadcrumb = ({
|
||||
id="projectDropdownTrigger"
|
||||
asChild>
|
||||
<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>
|
||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{isProjectDropdownOpen ? (
|
||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
|
||||
<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>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start" className="mt-2">
|
||||
<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} />
|
||||
{t("common.choose_project")}
|
||||
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.choose_workspace")}
|
||||
</div>
|
||||
{isLoadingProjects && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
@@ -234,7 +234,7 @@ export const ProjectBreadcrumb = ({
|
||||
)}
|
||||
{!isLoadingProjects && !loadError && (
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||
{projects.map((proj) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={proj.id}
|
||||
@@ -251,7 +251,7 @@ export const ProjectBreadcrumb = ({
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={handleAddProject}
|
||||
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} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
@@ -261,7 +261,7 @@ export const ProjectBreadcrumb = ({
|
||||
<DropdownMenuSeparator />
|
||||
<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} />
|
||||
{t("common.project_configuration")}
|
||||
{t("common.workspace_configuration")}
|
||||
</div>
|
||||
{projectSettings.map((setting) => (
|
||||
<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) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
|
||||
@@ -16,7 +16,7 @@ export const IntegrationsTip = ({ environmentId }: IntegrationsTipProps) => {
|
||||
<p className="text-sm">
|
||||
{t("environments.settings.notifications.need_slack_or_discord_notifications")}?
|
||||
<a
|
||||
href={`/environments/${environmentId}/project/integrations`}
|
||||
href={`/environments/${environmentId}/workspace/integrations`}
|
||||
className="ml-1 cursor-pointer text-sm underline">
|
||||
{t("environments.settings.notifications.use_the_integration")}
|
||||
</a>
|
||||
|
||||
@@ -47,6 +47,13 @@ export const OrganizationSettingsNavbar = ({
|
||||
current: pathname?.includes("/api-keys"),
|
||||
hidden: !isOwner,
|
||||
},
|
||||
{
|
||||
id: "domain",
|
||||
label: t("common.domain"),
|
||||
href: `/environments/${environmentId}/settings/domain`,
|
||||
current: pathname?.includes("/domain"),
|
||||
hidden: isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
id: "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,
|
||||
},
|
||||
{
|
||||
title: t("environments.project.languages.multi_language_surveys"),
|
||||
title: t("environments.workspace.languages.multi_language_surveys"),
|
||||
comingSoon: 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">
|
||||
<svg
|
||||
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">
|
||||
<circle
|
||||
cx={512}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { ShieldCheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const SecurityListTip = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<div className="flex items-center space-x-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm">
|
||||
<ShieldCheckIcon className="h-5 w-5 flex-shrink-0 text-blue-400" />
|
||||
<p className="text-sm">
|
||||
{t("environments.settings.general.security_list_tip")}{" "}
|
||||
<Link
|
||||
href="https://formbricks.com/security#stay-informed-with-formbricks-security-updates"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-blue-700">
|
||||
{t("environments.settings.general.security_list_tip_link")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { SettingsCard } from "../../components/SettingsCard";
|
||||
import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
||||
import { SecurityListTip } from "./components/SecurityListTip";
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const params = await props.params;
|
||||
@@ -48,6 +49,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
{!IS_FORMBRICKS_CLOUD && <SecurityListTip />}
|
||||
<SettingsCard
|
||||
title={t("environments.settings.general.organization_name")}
|
||||
description={t("environments.settings.general.organization_name_description")}>
|
||||
|
||||
@@ -21,7 +21,7 @@ const Layout = async (props) => {
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
}
|
||||
|
||||
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")}
|
||||
</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">
|
||||
{t("common.connect")}
|
||||
</Button>
|
||||
|
||||
@@ -213,6 +213,7 @@ export const SurveyAnalysisCTA = ({
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isReadOnly={isReadOnly}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
projectCustomScripts={project.customHeadScripts}
|
||||
/>
|
||||
)}
|
||||
<SuccessMessage environment={environment} survey={survey} />
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import {
|
||||
Code2Icon,
|
||||
LinkIcon,
|
||||
CodeIcon,
|
||||
Link2Icon,
|
||||
MailIcon,
|
||||
QrCodeIcon,
|
||||
Settings,
|
||||
@@ -18,10 +19,12 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
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 { 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 { 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 { 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 { 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";
|
||||
@@ -50,6 +53,7 @@ interface ShareSurveyModalProps {
|
||||
isFormbricksCloud: boolean;
|
||||
isReadOnly: boolean;
|
||||
isStorageConfigured: boolean;
|
||||
projectCustomScripts?: string | null;
|
||||
}
|
||||
|
||||
export const ShareSurveyModal = ({
|
||||
@@ -64,6 +68,7 @@ export const ShareSurveyModal = ({
|
||||
isFormbricksCloud,
|
||||
isReadOnly,
|
||||
isStorageConfigured,
|
||||
projectCustomScripts,
|
||||
}: ShareSurveyModalProps) => {
|
||||
const environmentId = survey.environmentId;
|
||||
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
|
||||
@@ -80,13 +85,13 @@ export const ShareSurveyModal = ({
|
||||
componentType: React.ComponentType<unknown>;
|
||||
componentProps: unknown;
|
||||
disabled?: boolean;
|
||||
}[] = useMemo(
|
||||
() => [
|
||||
}[] = useMemo(() => {
|
||||
const tabs = [
|
||||
{
|
||||
id: ShareViaType.ANON_LINKS,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
label: t("environments.surveys.share.anonymous_links.nav_title"),
|
||||
icon: LinkIcon,
|
||||
icon: Link2Icon,
|
||||
title: t("environments.surveys.share.anonymous_links.nav_title"),
|
||||
description: t("environments.surveys.share.anonymous_links.description"),
|
||||
componentType: AnonymousLinksTab,
|
||||
@@ -180,22 +185,49 @@ export const ShareSurveyModal = ({
|
||||
componentType: LinkSettingsTab,
|
||||
componentProps: { isReadOnly, locale: user.locale, isStorageConfigured },
|
||||
},
|
||||
],
|
||||
[
|
||||
t,
|
||||
survey,
|
||||
publicDomain,
|
||||
user.locale,
|
||||
surveyUrl,
|
||||
isReadOnly,
|
||||
environmentId,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
email,
|
||||
isStorageConfigured,
|
||||
]
|
||||
);
|
||||
{
|
||||
id: ShareSettingsType.PRETTY_URL,
|
||||
type: LinkTabsType.SHARE_SETTING,
|
||||
label: t("environments.surveys.share.pretty_url.title"),
|
||||
icon: Link2Icon,
|
||||
title: t("environments.surveys.share.pretty_url.title"),
|
||||
description: t("environments.surveys.share.pretty_url.description"),
|
||||
componentType: PrettyUrlTab,
|
||||
componentProps: { publicDomain, isReadOnly },
|
||||
},
|
||||
{
|
||||
id: ShareSettingsType.CUSTOM_HTML,
|
||||
type: LinkTabsType.SHARE_SETTING,
|
||||
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(() => {
|
||||
if (survey.type !== "link") {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { CirclePlayIcon, CopyIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -34,7 +34,6 @@ export const AnonymousLinksTab = ({
|
||||
locale,
|
||||
isReadOnly,
|
||||
}: AnonymousLinksTabProps) => {
|
||||
const surveyUrlWithCustomSuid = `${surveyUrl}?suId=CUSTOM-ID`;
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -49,6 +48,12 @@ export const AnonymousLinksTab = ({
|
||||
pendingAction: () => Promise<void> | void;
|
||||
} | null>(null);
|
||||
|
||||
const surveyUrlWithCustomSuid = useMemo(() => {
|
||||
const url = new URL(surveyUrl);
|
||||
url.searchParams.set("suId", "CUSTOM-ID");
|
||||
return url.toString();
|
||||
}, [surveyUrl]);
|
||||
|
||||
const resetState = () => {
|
||||
const { singleUse } = survey;
|
||||
const { enabled, isEncrypted } = singleUse ?? {};
|
||||
@@ -177,7 +182,11 @@ export const AnonymousLinksTab = ({
|
||||
|
||||
if (!!response?.data?.length) {
|
||||
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
|
||||
const csvContent = surveyLinks.join("\n");
|
||||
|
||||
@@ -163,7 +163,7 @@ export const AppTab = () => {
|
||||
</AlertDescription>
|
||||
{!environment.appSetupCompleted && (
|
||||
<AlertButton asChild>
|
||||
<Link href={`/environments/${environment.id}/project/app-connection`}>
|
||||
<Link href={`/environments/${environment.id}/workspace/app-connection`}>
|
||||
{t("common.connect_formbricks")}
|
||||
</Link>
|
||||
</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">
|
||||
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
||||
{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>
|
||||
<Link
|
||||
href={`/environments/${environmentId}/settings/notifications`}
|
||||
@@ -75,7 +75,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
|
||||
{t("environments.surveys.summary.configure_alerts")}
|
||||
</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">
|
||||
<BlocksIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
||||
{t("environments.surveys.summary.setup_integrations")}
|
||||
|
||||
@@ -13,7 +13,7 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
|
||||
}
|
||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
throw new Error("Workspace not found");
|
||||
}
|
||||
|
||||
const styling = getStyling(project, survey);
|
||||
|
||||
@@ -12,6 +12,8 @@ export enum ShareViaType {
|
||||
|
||||
export enum ShareSettingsType {
|
||||
LINK_SETTINGS = "link-settings",
|
||||
PRETTY_URL = "pretty-url",
|
||||
CUSTOM_HTML = "custom-html",
|
||||
}
|
||||
|
||||
export enum LinkTabsType {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ListOrderedIcon,
|
||||
MessageSquareTextIcon,
|
||||
MousePointerClickIcon,
|
||||
NetworkIcon,
|
||||
PieChartIcon,
|
||||
Rows3Icon,
|
||||
SmartphoneIcon,
|
||||
@@ -99,6 +100,7 @@ const elementIcons = {
|
||||
action: MousePointerClickIcon,
|
||||
country: FlagIcon,
|
||||
url: LinkIcon,
|
||||
ipAddress: NetworkIcon,
|
||||
|
||||
// others
|
||||
Language: LanguagesIcon,
|
||||
@@ -190,7 +192,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
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
|
||||
|
||||
@@ -17,9 +17,9 @@ import {
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/BaseSelectDropdown";
|
||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/BaseSelectDropdown";
|
||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
|
||||
import AirtableLogo from "@/images/airtableLogo.svg";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
@@ -58,12 +58,12 @@ const ElementCheckbox = ({
|
||||
onChange: (value: string[]) => void;
|
||||
};
|
||||
}) => {
|
||||
const handleCheckedChange = (checked: boolean) => {
|
||||
if (checked) {
|
||||
field.onChange([...(field.value || []), element.id]);
|
||||
} else {
|
||||
field.onChange(field.value?.filter((value) => value !== element.id) || []);
|
||||
}
|
||||
const addElement = () => {
|
||||
field.onChange([...(field.value || []), element.id]);
|
||||
};
|
||||
|
||||
const removeElement = () => {
|
||||
field.onChange(field.value?.filter((value) => value !== element.id) || []);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -75,7 +75,7 @@ const ElementCheckbox = ({
|
||||
value={element.id}
|
||||
className="bg-white"
|
||||
checked={field.value?.includes(element.id)}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
onCheckedChange={(checked) => (checked ? addElement() : removeElement())}
|
||||
/>
|
||||
<span className="ml-2">
|
||||
{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 { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
|
||||
import airtableLogo from "@/images/airtableLogo.svg";
|
||||
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 { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AddIntegrationModal";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AddIntegrationModal";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -1,8 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AirtableWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
||||
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||
import { getAirtableTables } from "@/lib/airtable/service";
|
||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
@@ -42,7 +42,7 @@ const Page = async (props) => {
|
||||
|
||||
return (
|
||||
<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")} />
|
||||
<div className="h-[75vh] w-full">
|
||||
<AirtableWrapper
|
||||
@@ -12,13 +12,13 @@ import {
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/actions";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions";
|
||||
import {
|
||||
constructGoogleSheetsUrl,
|
||||
extractSpreadsheetIdFromUrl,
|
||||
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 { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
@@ -266,7 +266,7 @@ export const AddIntegrationModal = ({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<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">
|
||||
{surveyElements.map((question) => (
|
||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/google";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google";
|
||||
import googleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||
import { AddIntegrationModal } from "./AddIntegrationModal";
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
TIntegrationGoogleSheetsConfigData,
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
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 { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -10,7 +10,7 @@ const Loading = () => {
|
||||
<div className="mt-6 p-6">
|
||||
<GoBackButton />
|
||||
<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")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -51,7 +51,7 @@ const Loading = () => {
|
||||
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></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>
|
||||
<div className="text-center"></div>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
||||
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||
import {
|
||||
GOOGLE_SHEETS_CLIENT_ID,
|
||||
GOOGLE_SHEETS_CLIENT_SECRET,
|
||||
@@ -40,7 +40,7 @@ const Page = async (props) => {
|
||||
|
||||
return (
|
||||
<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")} />
|
||||
<div className="h-[75vh] w-full">
|
||||
<GoogleSheetWrapper
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
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 toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -15,17 +15,15 @@ import {
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
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 {
|
||||
ERRORS,
|
||||
TYPE_MAPPING,
|
||||
UNSUPPORTED_TYPES_BY_NOTION,
|
||||
} from "@/app/(app)/environments/[environmentId]/project/integrations/notion/constants";
|
||||
MappingRow,
|
||||
TMapping,
|
||||
createEmptyMapping,
|
||||
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/MappingRow";
|
||||
import NotionLogo from "@/images/notion.png";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { getElementTypes } from "@/modules/survey/lib/elements";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -39,59 +37,6 @@ import {
|
||||
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
||||
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 {
|
||||
environmentId: string;
|
||||
surveys: TSurvey[];
|
||||
@@ -115,21 +60,7 @@ export const AddIntegrationModal = ({
|
||||
const { handleSubmit } = useForm();
|
||||
const [selectedDatabase, setSelectedDatabase] = useState<TIntegrationNotionDatabase | null>();
|
||||
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
|
||||
const [mapping, setMapping] = useState<
|
||||
{
|
||||
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 [mapping, setMapping] = useState<TMapping[]>([createEmptyMapping()]);
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
const [isLinkingDatabase, setIsLinkingDatabase] = useState(false);
|
||||
const integrationData = {
|
||||
@@ -234,7 +165,7 @@ export const AddIntegrationModal = ({
|
||||
return survey.id === selectedIntegration.surveyId;
|
||||
})!
|
||||
);
|
||||
setMapping(selectedIntegration.mapping);
|
||||
setMapping(selectedIntegration.mapping.map((m) => ({ ...m, id: createId() })));
|
||||
return;
|
||||
}
|
||||
resetForm();
|
||||
@@ -320,154 +251,11 @@ export const AddIntegrationModal = ({
|
||||
setSelectedDatabase(null);
|
||||
setSelectedSurvey(null);
|
||||
};
|
||||
const getFilteredElementItems = (selectedIdx) => {
|
||||
const selectedElementIds = 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>
|
||||
const getFilteredElementItems = (selectedIdx: number) => {
|
||||
const selectedElementIds = new Set(
|
||||
mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.element.id)
|
||||
);
|
||||
return elementItems.filter((el) => !selectedElementIds.has(el.id));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -539,8 +327,17 @@ export const AddIntegrationModal = ({
|
||||
{t("environments.integrations.notion.map_formbricks_fields_to_notion_property")}
|
||||
</Label>
|
||||
<div className="mt-1 space-y-2 overflow-y-auto">
|
||||
{mapping.map((_, idx) => (
|
||||
<MappingRow idx={idx} key={idx} />
|
||||
{mapping.map((m, idx) => (
|
||||
<MappingRow
|
||||
key={m.id}
|
||||
idx={idx}
|
||||
mapping={mapping}
|
||||
setMapping={setMapping}
|
||||
filteredElementItems={getFilteredElementItems(idx)}
|
||||
dbItems={dbItems}
|
||||
elementItems={elementItems}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -6,7 +6,7 @@ import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
||||
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 { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
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";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/AddIntegrationModal";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/ManageIntegration";
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/AddIntegrationModal";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/ManageIntegration";
|
||||
import notionLogo from "@/images/notion.png";
|
||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||
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">
|
||||
<GoBackButton />
|
||||
<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")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -48,7 +48,7 @@ const Loading = () => {
|
||||
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></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>
|
||||
<div className="text-center"></div>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
||||
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/NotionWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper";
|
||||
import {
|
||||
NOTION_AUTH_URL,
|
||||
NOTION_OAUTH_CLIENT_ID,
|
||||
@@ -2,7 +2,7 @@ import { TFunction } from "i18next";
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
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 AirtableLogo from "@/images/airtableLogo.svg";
|
||||
import GoogleSheetsLogo from "@/images/googleSheetsLogo.png";
|
||||
@@ -79,7 +79,7 @@ const Page = async (props) => {
|
||||
disabled: isReadOnly,
|
||||
},
|
||||
{
|
||||
connectHref: `/environments/${params.environmentId}/project/integrations/webhooks`,
|
||||
connectHref: `/environments/${params.environmentId}/workspace/integrations/webhooks`,
|
||||
connectText: t("environments.integrations.manage_webhooks"),
|
||||
connectNewTab: false,
|
||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks",
|
||||
@@ -93,7 +93,7 @@ const Page = async (props) => {
|
||||
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")}`,
|
||||
connectNewTab: false,
|
||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/google-sheets",
|
||||
@@ -107,7 +107,7 @@ const Page = async (props) => {
|
||||
disabled: isReadOnly,
|
||||
},
|
||||
{
|
||||
connectHref: `/environments/${params.environmentId}/project/integrations/airtable`,
|
||||
connectHref: `/environments/${params.environmentId}/workspace/integrations/airtable`,
|
||||
connectText: `${isAirtableIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||
connectNewTab: false,
|
||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/airtable",
|
||||
@@ -121,7 +121,7 @@ const Page = async (props) => {
|
||||
disabled: isReadOnly,
|
||||
},
|
||||
{
|
||||
connectHref: `/environments/${params.environmentId}/project/integrations/slack`,
|
||||
connectHref: `/environments/${params.environmentId}/workspace/integrations/slack`,
|
||||
connectText: `${isSlackIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||
connectNewTab: false,
|
||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/slack",
|
||||
@@ -163,7 +163,7 @@ const Page = async (props) => {
|
||||
disabled: isReadOnly,
|
||||
},
|
||||
{
|
||||
connectHref: `/environments/${params.environmentId}/project/integrations/notion`,
|
||||
connectHref: `/environments/${params.environmentId}/workspace/integrations/notion`,
|
||||
connectText: `${isNotionIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||
connectNewTab: false,
|
||||
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",
|
||||
docsText: t("common.docs"),
|
||||
docsNewTab: true,
|
||||
connectHref: `/environments/${params.environmentId}/project/app-connection`,
|
||||
connectHref: `/environments/${params.environmentId}/workspace/app-connection`,
|
||||
connectText: t("common.connect"),
|
||||
connectNewTab: false,
|
||||
label: "Javascript SDK",
|
||||
@@ -209,7 +209,7 @@ const Page = async (props) => {
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.project_configuration")}>
|
||||
<PageHeader pageTitle={t("common.workspace_configuration")}>
|
||||
<ProjectConfigNavigation environmentId={params.environmentId} activeId="integrations" />
|
||||
</PageHeader>
|
||||
<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";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
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 { recallToHeadline } from "@/lib/utils/recall";
|
||||
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 { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
||||
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 { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
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 { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getSlackChannelsAction } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/actions";
|
||||
import { AddChannelMappingModal } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/components/AddChannelMappingModal";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/lib/slack";
|
||||
import { getSlackChannelsAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/actions";
|
||||
import { AddChannelMappingModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/AddChannelMappingModal";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/lib/slack";
|
||||
import slackLogo from "@/images/slacklogo.png";
|
||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
||||
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/components/SlackWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||
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 { getIntegrationByType } from "@/lib/integration/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
@@ -32,7 +32,7 @@ const Page = async (props) => {
|
||||
|
||||
return (
|
||||
<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")} />
|
||||
<div className="h-[75vh] w-full">
|
||||
<SlackWrapper
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user