Compare commits

..

34 Commits

Author SHA1 Message Date
Matti Nannt 16ea39bcf2 chore: mechanical react-doctor sweep (Tailwind shorthands + button type)
Pure mechanical refactor to clear ~930 react-doctor warnings across
three rules. No behavioural change intended.

Applied via small Python codemods (in `/tmp/codemod-*.py`, not
committed) driven off react-doctor's own file:line diagnostic, so each
edit lands on a flagged location and only on a flagged location:

1. react-doctor/design-no-redundant-size-axes (621 changes, 277 files)
   `w-N h-N` (same N) → `size-N` (Tailwind v3.4+ shorthand)

2. react-doctor/design-no-space-on-flex-children (251 changes, 154 files)
   `space-x-N` / `space-y-N` on flex/grid parents → `gap-x-N` / `gap-y-N`
   Avoids the negative-margin "phantom gap" on conditional render and
   the RTL-mirroring miss. Skipped `space-*-reverse` (different
   semantic).

3. react-doctor/button-has-type (51 changes, 45 files)
   `<button>` without an explicit `type=` attribute → adds
   `type="button"`. The codemod scans the full opening tag before
   inserting so it never double-adds.

Verified with `pnpm --filter @formbricks/web test`: **5166 / 5166 pass**.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:09:33 +02:00
Bhagya Amarasinghe be5beaeed7 fix: harden Helm release secret lookups (#8118) 2026-05-22 11:54:20 +00:00
Dhruwang Jariwala bc56f99fd8 feat: cascade delete Hub feedback records on org deletion (ENG-973) (#8055)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:51:00 +00:00
Harsh Bhat 0f38627627 docs: restructure into Platform, Surveys, and Unify Feedback tabs (#8114)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-05-22 09:09:03 +00:00
Bhagya Amarasinghe a878bdff42 fix: limit JSON request body size (#8051)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-22 08:09:45 +00:00
Bhagya Amarasinghe d757e12c76 fix: return 404 when response is deleted mid-update (#8049) 2026-05-22 07:58:35 +00:00
Bhagya Amarasinghe 629febb2f7 fix: order Helm Hub migrations after Prisma (#8104) 2026-05-22 06:31:11 +00:00
Bhagya Amarasinghe 40b93cc834 fix: use Valkey for bundled Helm Redis (#8092) 2026-05-22 05:56:57 +00:00
Anshuman Pandey f41d2c14f1 fix: pin DNS and block redirects on webhook delivery in the response pipeline (#8095)
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
2026-05-22 04:46:20 +00:00
Matti Nannt af51414b03 fix: remove isAIDataAnalysisEnabled (ENG-1039) (#8109)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:54:36 +00:00
Matti Nannt a9e39dd4ab fix: validate displayId ownership on response creation (ENG-825) (#8046)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 13:11:19 +00:00
Johannes c8b0bb2225 fix: reserve future contact keys and improve segment errors (ENG-1037, ENG-994) (#8101)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 11:41:23 +00:00
Dhruwang Jariwala f6aa27ba8c fix: chart date range type switch + presets include today (ENG-1034, ENG-1035) (#8096)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-21 11:05:10 +00:00
Johannes 82765f7dd7 fix: allow enterprise oauth display names (#8099)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-21 10:59:35 +00:00
Dhruwang Jariwala d5bbafcf90 fix: remount AI translation editor on value change, not disabled transition (#8084) 2026-05-21 10:09:57 +00:00
Anshuman Pandey db87a588b5 fix: adds close button on response error screen (#8093) 2026-05-21 09:26:47 +00:00
Javi Aguilar c834587c8d chore: add typecheck command and fix format and type issues (#7999) 2026-05-21 08:13:46 +00:00
Anshuman Pandey ef18aacfa2 fix: fixes responseId client api issue with legacy environmentId (#8079) 2026-05-21 06:15:27 +00:00
Dhruwang Jariwala 025a766c57 fix: show copy icon on legacy environmentId, reintroduce duplicate survey action (ENG-978, ENG-987) (#8061)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:21:33 +00:00
Bhagya Amarasinghe f476db3128 fix: update Helm chart default image tag (#8072) 2026-05-21 05:11:20 +00:00
Bhagya Amarasinghe 37023275ca fix: require Cube API secret in compose (#8071) 2026-05-21 05:07:57 +00:00
Bhagya Amarasinghe 9266f64588 fix: harden Helm env value rendering (#8070) 2026-05-21 05:01:10 +00:00
Dhruwang Jariwala 032066194b fix: render scheduled-plan-change description placeholders correctly (#8064)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:58:39 +00:00
Dhruwang Jariwala 0bef023302 fix: gate AI chart generation on smartTools, not dataAnalysis (ENG-1001) (#8060)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:53:42 +00:00
Dhruwang Jariwala aa83ee336c fix: route Manage Teams and integration OAuth callbacks to settings (ENG-988) (#8059)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:51:47 +00:00
Anshuman Pandey 4357f497a1 fix: sanitize CSV/XLSX exports against formula injection (#8045) 2026-05-21 04:49:50 +00:00
Bhagya Amarasinghe 526c17af23 fix: wire Cube API secret into Helm defaults (#8068) 2026-05-21 04:47:15 +00:00
Matti Nannt a0ddadebad fix: scope display contact lookup to workspace (ENG-818) (#8048)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 04:41:48 +00:00
Bhagya Amarasinghe bc0d04f5e8 fix: staging AI chart Cube schema (#8057) 2026-05-20 14:22:23 +00:00
Anshuman Pandey f0967c2e23 fix: preserve legacy SDK shape with placeholder segment data (#8067) 2026-05-20 16:21:13 +02:00
Johannes 13c9677edd fix: correct settings sidebar back navigation behavior (#8052)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 11:18:12 +00:00
Johannes c0bf2ab7cc fix: enforce billing-only settings access (#8053)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 11:14:43 +00:00
Johannes 65d0f4ac0e fix: add CSAT and CES summary filter icons (#8056)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 09:44:10 +00:00
Matti Nannt 655c0b5e47 fix: strip client-provided timestamps in client response API (ENG-828) (#8047)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 06:53:42 +00:00
679 changed files with 6155 additions and 2277 deletions
+6 -6
View File
@@ -53,7 +53,7 @@ function {QuestionType}({
}: {QuestionType}Props): React.JSX.Element {
// Ensure value is always the correct type (handle undefined/null)
const currentValue = value ?? {defaultValue};
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
@@ -63,11 +63,11 @@ function {QuestionType}({
return (
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
/>
{/* Question-specific controls */}
+87 -5
View File
@@ -31,14 +31,14 @@ jobs:
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# Get the latest release tag from GitHub API with error handling
echo "Fetching latest release from GitHub API..."
# Use curl with error handling - API returns 404 if no releases exist
http_code=$(curl -s -w "%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" \
"https://api.github.com/repos/${REPO}/releases/latest" -o /tmp/latest_release.json)
if [[ "$http_code" == "404" ]]; then
echo "⚠️ No previous releases found (404). This appears to be the first release."
echo "latest_release=" >> $GITHUB_OUTPUT
@@ -55,7 +55,7 @@ jobs:
echo "❌ GitHub API error (HTTP ${http_code}). Treating as first release."
echo "latest_release=" >> $GITHUB_OUTPUT
fi
echo "Current release tag: ${{ github.event.release.tag_name }}"
- name: Compare release tags
@@ -65,7 +65,7 @@ jobs:
LATEST_TAG: ${{ steps.get_latest_release.outputs.latest_release }}
run: |
set -euo pipefail
# Handle first release case (no previous releases)
if [[ -z "${LATEST_TAG}" ]]; then
echo "🎉 This is the first release (${CURRENT_TAG}) - treating as latest"
@@ -156,6 +156,87 @@ jobs:
is_prerelease: ${{ github.event.release.prerelease }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
update-helm-app-version:
name: Create Helm app version update
runs-on: ubuntu-latest
timeout-minutes: 5
needs:
- docker-build-community
- helm-chart-release
if: ${{ !github.event.release.prerelease }}
permissions:
contents: write
pull-requests: write
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout main
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: main
- name: Install YQ
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
- name: Prepare Helm app version update
id: update
env:
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
run: |
set -euo pipefail
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Skipping Helm app version source update for non-stable version: ${VERSION}"
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
yq -i ".appVersion = \"${VERSION}\"" charts/formbricks/Chart.yaml
perl -0pi -e "s/!\[AppVersion: [^\]]+\]/![AppVersion: ${VERSION}]/" charts/formbricks/README.md
perl -0pi -e "s/AppVersion-[0-9A-Za-z._+-]+-informational/AppVersion-${VERSION}-informational/" charts/formbricks/README.md
if git diff --quiet -- charts/formbricks/Chart.yaml charts/formbricks/README.md; then
echo "Helm chart appVersion already matches ${VERSION}"
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "changed=true" >> "$GITHUB_OUTPUT"
- name: Create Helm app version PR
if: steps.update.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
run: |
set -euo pipefail
branch="chore/update-helm-app-version-${VERSION}"
title="chore: update Helm app version to ${VERSION}"
body_file="$(mktemp)"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$branch"
git add charts/formbricks/Chart.yaml charts/formbricks/README.md
git commit -m "$title"
git push --force-with-lease origin "$branch"
cat > "$body_file" <<EOF
Updates the Helm chart default app version after publishing stable Formbricks release ${VERSION}.
Release candidates and pre-releases do not create this source update.
EOF
if gh pr view "$branch" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh pr edit "$branch" --repo "$GITHUB_REPOSITORY" --title "$title" --body-file "$body_file" --base main
else
gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$branch" --title "$title" --body-file "$body_file"
fi
linear-release-complete:
name: Mark Linear release as complete
runs-on: ubuntu-latest
@@ -165,6 +246,7 @@ jobs:
- docker-build-cloud
- helm-chart-release
- move-stable-tag
- update-helm-app-version
if: ${{ !github.event.release.prerelease }}
steps:
- name: Harden the runner
+20 -1
View File
@@ -47,7 +47,7 @@ jobs:
- name: Set up Helm
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
with:
version: latest
version: v3.15.4
- name: Log in to GitHub Container Registry
env:
@@ -70,6 +70,25 @@ jobs:
echo "✅ Successfully updated Chart.yaml"
- name: Validate default Formbricks image tag
env:
VERSION: ${{ env.VERSION }}
run: |
set -euo pipefail
rendered="$(helm template qa charts/formbricks \
--set formbricks.webappUrl=https://qa.example.com \
--show-only templates/deployment.yaml \
--show-only templates/migration-job.yaml)"
expected_image="ghcr.io/formbricks/formbricks:${VERSION}"
image_count="$(grep -c "image: ${expected_image}$" <<< "$rendered" || true)"
if [[ "$image_count" -ne 2 ]]; then
echo "Expected web Deployment and migration Job to render ${expected_image}; found ${image_count} matches"
grep "image: ghcr.io/formbricks/formbricks:" <<< "$rendered" || true
exit 1
fi
- name: Package Helm chart
env:
VERSION: ${{ env.VERSION }}
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"lint": "eslint . --config .eslintrc.cjs --ext .ts,.tsx --report-unused-disable-directives --max-warnings 0",
"typecheck": "tsc --noEmit",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
+1 -1
View File
@@ -1,6 +1,6 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { App } from "./App.tsx";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
@@ -120,30 +120,31 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
<DropdownMenuTrigger asChild className={switcherTriggerClasses}>
<button type="button" className="flex w-full items-center gap-3">
<span className={switcherIconClasses}>
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
<Building2Icon className="size-4" strokeWidth={1.5} />
</span>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
<p className="text-sm text-slate-500">{t("common.organization")}</p>
</div>
{isPending && <Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
{isPending && <Loader2 className="size-4 animate-spin text-slate-600" strokeWidth={1.5} />}
<ChevronRightIcon className="size-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Building2Icon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
<Building2Icon className="mr-2 inline size-4" strokeWidth={1.5} />
{t("common.change_organization")}
</div>
{isLoadingOrganizations && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="size-4 animate-spin" />
</div>
)}
{!isLoadingOrganizations && organizationLoadError && (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{organizationLoadError}</p>
<button
type="button"
onClick={() => {
setOrganizationLoadError(null);
setOrganizations([]);
@@ -171,7 +172,7 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
onClick={() => setOpenCreateOrganizationModal(true)}
className="w-full cursor-pointer justify-between">
<span>{t("common.create_new_organization")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
<PlusIcon className="ml-2 size-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
@@ -197,7 +198,7 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
</p>
<p className="text-sm text-slate-500">{t("common.account")}</p>
</div>
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
<ChevronRightIcon className="size-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</button>
</DropdownMenuTrigger>
@@ -210,7 +211,7 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}
className="flex w-full items-center">
<DropdownMenuItem>
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
<link.icon className="mr-2 size-4" strokeWidth={1.5} />
{link.label}
</DropdownMenuItem>
</Link>
@@ -225,7 +226,7 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
callbackUrl: "/auth/login",
});
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
icon={<LogOutIcon className="mr-2 size-4" strokeWidth={1.5} />}>
{t("common.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
@@ -46,7 +46,7 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
isMembershipPending={isMembershipPending}
/>
</div>
<div className="flex h-full flex-col items-center justify-center space-y-12">
<div className="flex h-full flex-col items-center justify-center gap-y-12">
<Header
title={t("organizations.landing.no_workspaces_warning_title")}
subtitle={t("organizations.landing.no_workspaces_warning_subtitle")}
@@ -53,7 +53,7 @@ const Page = async (props: ChannelPageProps) => {
);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<div className="flex min-h-full min-w-full flex-col items-center justify-center gap-y-12">
<Header
title={t("organizations.workspaces.new.channel.channel_select_title")}
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
@@ -65,7 +65,7 @@ const Page = async (props: ChannelPageProps) => {
variant="ghost"
asChild>
<Link href={"/"}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
<XIcon className="size-7" strokeWidth={1.5} />
</Link>
</Button>
)}
@@ -50,7 +50,7 @@ const Page = async (props: ModePageProps) => {
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<div className="flex min-h-full min-w-full flex-col items-center justify-center gap-y-12">
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
<OnboardingOptionsContainer options={channelOptions} />
{workspaces.length >= 1 && (
@@ -59,7 +59,7 @@ const Page = async (props: ModePageProps) => {
variant="ghost"
asChild>
<Link href={"/"}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
<XIcon className="size-7" strokeWidth={1.5} />
</Link>
</Button>
)}
@@ -125,8 +125,8 @@ export const WorkspaceSettings = ({
}));
return (
<div className="mt-6 flex w-5/6 space-x-10 lg:w-2/3 2xl:w-1/2">
<div className="flex w-1/2 flex-col space-y-4">
<div className="mt-6 flex w-5/6 gap-x-10 lg:w-2/3 2xl:w-1/2">
<div className="flex w-1/2 flex-col gap-y-4">
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(addWorkspace)} className="w-full space-y-4">
<FormField
@@ -224,7 +224,7 @@ export const WorkspaceSettings = ({
</FormProvider>
</div>
<div className="relative flex w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 p-6 shadow">
<div className="relative flex w-1/2 flex-col items-center justify-center gap-y-2 rounded-lg border bg-slate-200 p-6 shadow">
{logoUrl && (
<Image
src={logoUrl}
@@ -93,7 +93,7 @@ const Page = async (props: WorkspaceSettingsPageProps) => {
}
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<div className="flex min-h-full min-w-full flex-col items-center justify-center gap-y-12">
<Header
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
@@ -115,7 +115,7 @@ const Page = async (props: WorkspaceSettingsPageProps) => {
variant="ghost"
asChild>
<Link href={"/"}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
<XIcon className="size-7" strokeWidth={1.5} />
</Link>
</Button>
)}
@@ -27,7 +27,7 @@ export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContain
description={option.description}
loading={option.isLoading || false}>
<div className="flex flex-col items-center">
<Icon className="h-16 w-16 text-slate-600" strokeWidth={1} />
<Icon className="size-16 text-slate-600" strokeWidth={1} />
{option.iconText && (
<p className="mt-4 w-fit rounded-xl bg-slate-200 px-4 text-sm text-slate-700">
{option.iconText}
@@ -43,9 +43,9 @@ export const ConnectWithFormbricks = ({
}, []);
return (
<div className="mt-6 flex w-5/6 flex-col items-center space-y-10 lg:w-2/3 2xl:w-1/2">
<div className="flex w-full space-x-10">
<div className="flex w-1/2 flex-col space-y-4">
<div className="mt-6 flex w-5/6 flex-col items-center gap-y-10 lg:w-2/3 2xl:w-1/2">
<div className="flex w-full gap-x-10">
<div className="flex w-1/2 flex-col gap-y-4">
<OnboardingSetupInstructions
workspaceId={workspaceId}
publicDomain={publicDomain}
@@ -66,10 +66,10 @@ export const ConnectWithFormbricks = ({
</p>
</div>
) : (
<div className="flex animate-pulse flex-col items-center space-y-4">
<span className="relative flex h-10 w-10">
<div className="flex animate-pulse flex-col items-center gap-y-4">
<span className="relative flex size-10">
<span className="absolute inline-flex h-full w-full animate-ping-slow rounded-full bg-slate-400 opacity-75"></span>
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
<span className="relative inline-flex size-10 rounded-full bg-slate-500"></span>
</span>
<p className="pt-4 text-sm font-medium text-slate-600">
{t("workspace.connect.waiting_for_your_signal")}
@@ -134,7 +134,7 @@ export const OnboardingSetupInstructions = ({
</CodeBlock>
</div>
<div className="mt-4 flex justify-between space-x-2">
<div className="mt-4 flex justify-between gap-x-2">
<Button
id="onboarding-inapp-connect-copy-code"
variant={appSetupCompleted ? "secondary" : "default"}
@@ -45,7 +45,7 @@ const Page = async (props: ConnectPageProps) => {
variant="ghost"
asChild>
<Link href={`/workspaces/${params.workspaceId}`}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
<XIcon className="size-7" strokeWidth={1.5} />
</Link>
</Button>
</div>
@@ -38,7 +38,7 @@ const Page = async (props: XMTemplatePageProps) => {
const workspaces = await getUserWorkspaces(session.user.id, workspace.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<div className="flex min-h-full min-w-full flex-col items-center justify-center gap-y-12">
<Header title={t("workspace.xm-templates.headline")} />
<XMTemplateList workspace={workspace} user={user} workspaceId={params.workspaceId} />
{workspaces.length >= 2 && (
@@ -47,7 +47,7 @@ const Page = async (props: XMTemplatePageProps) => {
variant="ghost"
asChild>
<Link href={`/workspaces/${params.workspaceId}/surveys`}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
<XIcon className="size-7" strokeWidth={1.5} />
</Link>
</Button>
)}
@@ -194,7 +194,7 @@ export const MainNavigation = ({
const settingsNavigationItem = useMemo(
() => ({
name: t("common.settings"),
href: `/workspaces/${workspace.id}/settings`,
href: `/workspaces/${workspace.id}/settings/workspace/general`,
icon: SettingsIcon,
isActive: isSettingsMode,
disabled: isMembershipPending || isBilling,
@@ -238,7 +238,10 @@ export const MainNavigation = ({
const renderSwitcherError = (error: string, onRetry: () => void, retryLabel: string) => (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{error}</p>
<button onClick={onRetry} className="text-xs text-slate-600 underline hover:text-slate-800">
<button
type="button"
onClick={onRetry}
className="text-xs text-slate-600 underline hover:text-slate-800">
{retryLabel}
</button>
</div>
@@ -467,7 +470,7 @@ export const MainNavigation = ({
{isSettingsMode ? (
<div className="flex flex-col overflow-hidden">
<div className="mb-2 px-3">
<GoBackButton />
<GoBackButton url={`/workspaces/${workspace.id}/surveys`} />
</div>
{/* Settings sidebar content */}
@@ -578,9 +581,9 @@ export const MainNavigation = ({
<Link
href="https://github.com/formbricks/formbricks/releases"
target="_blank"
className="m-2 flex items-center space-x-4 rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-800 hover:border-slate-300 hover:bg-slate-200">
className="m-2 flex items-center gap-x-4 rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-800 hover:border-slate-300 hover:bg-slate-200">
<p className="flex items-center justify-center gap-x-2 text-xs">
<RocketIcon strokeWidth={1.5} className="mx-1 h-6 w-6 text-slate-900" />
<RocketIcon strokeWidth={1.5} className="mx-1 size-6 text-slate-900" />
{t("common.new_version_available", { version: latestVersion })}
</p>
</Link>
@@ -619,7 +622,7 @@ export const MainNavigation = ({
aria-label={isCollapsed ? t("common.change_workspace") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<FoldersIcon className="h-4 w-4" strokeWidth={1.5} />
<FoldersIcon className="size-4" strokeWidth={1.5} />
</span>
{!isCollapsed && !isTextVisible && (
<>
@@ -628,21 +631,21 @@ export const MainNavigation = ({
<p className="text-sm text-slate-500">{t("common.workspace")}</p>
</div>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
<Loader2 className="size-4 animate-spin text-slate-600" strokeWidth={1.5} />
)}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
<ChevronRightIcon className="size-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
<FoldersIcon className="mr-2 inline size-4" strokeWidth={1.5} />
{t("common.change_workspace")}
</div>
{(isLoadingWorkspaces || isInitialWorkspacesLoading) && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="size-4 animate-spin" />
</div>
)}
{!isLoadingWorkspaces &&
@@ -674,7 +677,7 @@ export const MainNavigation = ({
onClick={handleWorkspaceCreate}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
<PlusIcon className="ml-2 size-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
@@ -685,7 +688,7 @@ export const MainNavigation = ({
handleSettingNavigation(`/workspaces/${workspace.id}/settings/workspace/general`)
}
className="cursor-pointer">
<Cog className="mr-2 h-4 w-4" strokeWidth={1.5} />
<Cog className="mr-2 size-4" strokeWidth={1.5} />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
@@ -701,7 +704,7 @@ export const MainNavigation = ({
aria-label={isCollapsed ? t("common.change_organization") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
<Building2Icon className="size-4" strokeWidth={1.5} />
</span>
{!isCollapsed && !isTextVisible && (
<>
@@ -710,21 +713,21 @@ export const MainNavigation = ({
<p className="text-sm text-slate-500">{t("common.organization")}</p>
</div>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
<Loader2 className="size-4 animate-spin text-slate-600" strokeWidth={1.5} />
)}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
<ChevronRightIcon className="size-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Building2Icon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
<Building2Icon className="mr-2 inline size-4" strokeWidth={1.5} />
{t("common.change_organization")}
</div>
{isLoadingOrganizations && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="size-4 animate-spin" />
</div>
)}
{!isLoadingOrganizations &&
@@ -755,7 +758,7 @@ export const MainNavigation = ({
onClick={() => setOpenCreateOrganizationModal(true)}
className="w-full cursor-pointer justify-between">
<span>{t("common.create_new_organization")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
<PlusIcon className="ml-2 size-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
@@ -766,7 +769,7 @@ export const MainNavigation = ({
handleSettingNavigation(`/workspaces/${workspace.id}/settings/organization/general`)
}
className="cursor-pointer">
<SettingsIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
<SettingsIcon className="mr-2 size-4" strokeWidth={1.5} />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
@@ -794,7 +797,7 @@ export const MainNavigation = ({
</p>
<p className="text-sm text-slate-500">{t("common.account")}</p>
</div>
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
<ChevronRightIcon className="size-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
@@ -814,7 +817,7 @@ export const MainNavigation = ({
key={link.label}
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}>
<DropdownMenuItem>
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
<link.icon className="mr-2 size-4" strokeWidth={1.5} />
{link.label}
</DropdownMenuItem>
</Link>
@@ -832,7 +835,7 @@ export const MainNavigation = ({
});
router.push(route?.url || loginUrl);
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
icon={<LogOutIcon className="mr-2 size-4" strokeWidth={1.5} />}>
{t("common.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
@@ -1,9 +1,9 @@
export const NavbarLoading = () => {
return (
<div>
<div className="flex justify-between space-x-4 px-4 py-2">
<div className="flex justify-between gap-x-4 px-4 py-2">
<div className="flex">
<div className="mx-2 h-8 w-8 animate-pulse rounded-md bg-slate-200"></div>
<div className="mx-2 size-8 animate-pulse rounded-md bg-slate-200"></div>
<div className="mx-2 h-8 w-20 animate-pulse rounded-md bg-slate-200"></div>
<div className="mx-2 h-8 w-20 animate-pulse rounded-md bg-slate-200"></div>
<div className="mx-2 h-8 w-20 animate-pulse rounded-md bg-slate-200"></div>
@@ -11,7 +11,7 @@ export const NavbarLoading = () => {
<div className="mx-2 h-8 w-20 animate-pulse rounded-md bg-slate-200"></div>
</div>
<div className="flex">
<div className="mx-2 h-8 w-8 animate-pulse rounded-full bg-slate-200"></div>
<div className="mx-2 size-8 animate-pulse rounded-full bg-slate-200"></div>
<div className="mx-2 h-8 w-20 animate-pulse rounded-md bg-slate-200"></div>
</div>
</div>
@@ -195,12 +195,12 @@ const SectionHeader = ({
<DropdownMenu onOpenChange={(open) => open && onSwitcherOpen?.()}>
<DropdownMenuTrigger className="ml-auto flex min-w-0 max-w-[50%] items-center gap-1 rounded-md border border-slate-200 px-2 py-0.5 text-xs text-slate-600 hover:bg-slate-50">
<span className="truncate">{switcherName}</span>
<ChevronDownIcon className="h-3 w-3" />
<ChevronDownIcon className="size-3" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-[300px]">
{isLoadingSwitcher ? (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="size-4 animate-spin" />
</div>
) : (
<DropdownMenuGroup className="overflow-y-auto">
@@ -335,6 +335,7 @@ export const SettingsSidebarContent = ({
href: `${basePath}/organization/feedback-directories`,
icon: <FoldersIcon className={iconClassName} />,
hidden: isMember,
disabled: !isOwnerOrManager,
},
{
id: "org-api-keys",
@@ -373,12 +374,14 @@ export const SettingsSidebarContent = ({
label: t("common.your_profile"),
href: `${basePath}/account/profile`,
icon: <UserCircleIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "notifications",
label: t("common.notifications"),
href: `${basePath}/account/notifications`,
icon: <BellIcon className={iconClassName} />,
disabled: isBilling,
},
];
@@ -406,7 +409,7 @@ export const SettingsSidebarContent = ({
<div className="flex flex-col overflow-y-auto">
<div>
<SectionHeader
icon={<FoldersIcon className="h-4 w-4" />}
icon={<FoldersIcon className="size-4" />}
label={t("common.workspace")}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
@@ -422,7 +425,7 @@ export const SettingsSidebarContent = ({
<div>
<SectionHeader
icon={<Building2Icon className="h-4 w-4" />}
icon={<Building2Icon className="size-4" />}
label={t("common.organization")}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
@@ -438,7 +441,7 @@ export const SettingsSidebarContent = ({
<div>
<SectionHeader
icon={<UserCircleIcon className="h-4 w-4" />}
icon={<UserCircleIcon className="size-4" />}
label={t("common.account")}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
@@ -124,13 +124,13 @@ export const OrganizationBreadcrumb = ({
id="organizationDropdownTrigger"
asChild>
<div className="flex items-center gap-1">
<Building2Icon className="h-3 w-3" strokeWidth={1.5} />
<Building2Icon className="size-3" strokeWidth={1.5} />
<span>{organizationName}</span>
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isPending && <Loader2 className="size-3 animate-spin" strokeWidth={1.5} />}
{isOrganizationDropdownOpen ? (
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
<ChevronDownIcon className="size-3" strokeWidth={1.5} />
) : (
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
<ChevronRightIcon className="size-3" strokeWidth={1.5} />
)}
</div>
</DropdownMenuTrigger>
@@ -138,18 +138,19 @@ export const OrganizationBreadcrumb = ({
{showOrganizationDropdown && (
<>
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Building2Icon className="mr-2 inline h-4 w-4" />
<Building2Icon className="mr-2 inline size-4" />
{t("common.choose_organization")}
</div>
{isLoadingOrganizations && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="size-4 animate-spin" />
</div>
)}
{!isLoadingOrganizations && loadError && (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{loadError}</p>
<button
type="button"
onClick={() => {
setLoadError(null);
setOrganizations([]);
@@ -177,7 +178,7 @@ export const OrganizationBreadcrumb = ({
onClick={() => setOpenCreateOrganizationModal(true)}
className="cursor-pointer">
<span>{t("common.create_new_organization")}</span>
<PlusIcon className="ml-2 h-4 w-4" />
<PlusIcon className="ml-2 size-4" />
</DropdownMenuCheckboxItem>
)}
</>
@@ -190,7 +191,7 @@ export const OrganizationBreadcrumb = ({
<DropdownMenuCheckboxItem
onClick={() => handleSettingChange(`${workspaceBasePath}/settings/organization/general`)}
className="cursor-pointer">
<SettingsIcon className="mr-2 h-4 w-4" />
<SettingsIcon className="mr-2 size-4" />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</>
@@ -154,31 +154,32 @@ export const WorkspaceBreadcrumb = ({
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
<DropdownMenuTrigger className="flex cursor-pointer items-center gap-1 outline-none" asChild>
<div className="flex items-center gap-1">
<FoldersIcon className="h-3 w-3" strokeWidth={1.5} />
<FoldersIcon className="size-3" strokeWidth={1.5} />
<span>{workspaceName}</span>
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isPending && <Loader2 className="size-3 animate-spin" strokeWidth={1.5} />}
{isEnvironmentBreadcrumbVisible && !isWorkspaceDropdownOpen ? (
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
<ChevronRightIcon className="size-3" strokeWidth={1.5} />
) : (
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
<ChevronDownIcon className="size-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">
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
<FoldersIcon className="mr-2 inline size-4" strokeWidth={1.5} />
{t("common.choose_workspace")}
</div>
{isLoadingWorkspaces && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="size-4 animate-spin" />
</div>
)}
{!isLoadingWorkspaces && loadError && (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{loadError}</p>
<button
type="button"
onClick={() => {
setLoadError(null);
setWorkspaces([]);
@@ -211,7 +212,7 @@ export const WorkspaceBreadcrumb = ({
aria-disabled="true"
className="relative flex w-full cursor-not-allowed select-none items-center justify-between rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
<PlusIcon className="ml-2 size-4" strokeWidth={1.5} />
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
@@ -225,7 +226,7 @@ export const WorkspaceBreadcrumb = ({
onClick={handleAddWorkspace}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
<PlusIcon className="ml-2 size-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
@@ -236,7 +237,7 @@ export const WorkspaceBreadcrumb = ({
handleWorkspaceSettingsNavigation(`${workspaceBasePath}/settings/workspace/general`)
}
className="cursor-pointer">
<CogIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
<CogIcon className="mr-2 size-4" strokeWidth={1.5} />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
@@ -1,4 +1,11 @@
const AccountSettingsLayout = (props: { children: React.ReactNode }) => {
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
const AccountSettingsLayout = async (props: Readonly<{
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return <>{props.children}</>;
};
@@ -28,8 +28,8 @@ export const EditAlerts = ({
<>
{memberships.map((membership) => (
<div key={membership.organization.id}>
<div className="mb-5 grid grid-cols-6 items-center space-x-3">
<div className="col-span-3 flex items-center space-x-3">
<div className="mb-5 grid grid-cols-6 items-center gap-x-3">
<div className="col-span-3 flex items-center gap-x-3">
<UsersIcon className="h-6 w-7 text-slate-600" />
<p className="text-sm font-medium text-slate-800">{membership.organization.name}</p>
@@ -54,9 +54,9 @@ export const EditAlerts = ({
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<div className="col-span-1 flex cursor-default items-center justify-center space-x-2">
<div className="col-span-1 flex cursor-default items-center justify-center gap-x-2">
<span>{t("workspace.settings.notifications.every_response")}</span>
<HelpCircleIcon className="h-4 w-4 flex-shrink-0 text-slate-500" />
<HelpCircleIcon className="size-4 flex-shrink-0 text-slate-500" />
</div>
</TooltipTrigger>
<TooltipContent>
@@ -9,8 +9,8 @@ export const IntegrationsTip = () => {
const { workspace } = useWorkspace();
return (
<div>
<div className="flex max-w-4xl items-center space-y-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base">
<SlackIcon className="mr-3 h-4 w-4 text-blue-400" />
<div className="flex max-w-4xl items-center gap-y-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm md:gap-y-0 md:text-base">
<SlackIcon className="mr-3 size-4 text-blue-400" />
<p className="text-sm">
{t("workspace.settings.notifications.need_slack_or_discord_notifications")}?
<a
@@ -18,7 +18,7 @@ export const AccountSecurity = ({ user }: AccountSecurityProps) => {
return (
<div>
<div className="flex items-center space-x-4">
<div className="flex items-center gap-x-4">
<Switch
checked={user.twoFactorEnabled}
onCheckedChange={(checked) => {
@@ -225,7 +225,7 @@ export const EditProfileDetailsForm = ({
) : (
t("common.select")
)}
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
<ChevronDownIcon className="size-4 text-slate-500" />
</div>
</Button>
</DropdownMenuTrigger>
@@ -0,0 +1,54 @@
import { redirect } from "next/navigation";
import { describe, expect, test, vi } from "vitest";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { redirectBillingRoleFromRestrictedSettings } from "./redirect-billing-role";
const mocks = vi.hoisted(() => ({
getBillingFallbackPath: vi.fn(),
getWorkspaceAuth: vi.fn(),
isFormbricksCloud: false,
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: mocks.isFormbricksCloud,
}));
vi.mock("@/lib/membership/navigation", () => ({
getBillingFallbackPath: mocks.getBillingFallbackPath,
}));
vi.mock("@/modules/workspaces/lib/utils", () => ({
getWorkspaceAuth: mocks.getWorkspaceAuth,
}));
const workspaceId = "workspace-1";
const billingFallbackPath = `/workspaces/${workspaceId}/settings/organization/billing`;
const getWorkspaceAuthResponse = (isBilling: boolean) =>
({
isBilling,
}) as Awaited<ReturnType<typeof getWorkspaceAuth>>;
describe("redirectBillingRoleFromRestrictedSettings", () => {
test("does not redirect non-billing workspace members", async () => {
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(false));
await expect(redirectBillingRoleFromRestrictedSettings(workspaceId)).resolves.toBeUndefined();
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
expect(getBillingFallbackPath).not.toHaveBeenCalled();
expect(redirect).not.toHaveBeenCalled();
});
test("redirects billing users to the billing fallback path", async () => {
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(true));
vi.mocked(getBillingFallbackPath).mockReturnValue(billingFallbackPath);
await redirectBillingRoleFromRestrictedSettings(workspaceId);
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
expect(getBillingFallbackPath).toHaveBeenCalledWith(workspaceId, mocks.isFormbricksCloud);
expect(redirect).toHaveBeenCalledWith(billingFallbackPath);
});
});
@@ -0,0 +1,12 @@
import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
export const redirectBillingRoleFromRestrictedSettings = async (workspaceId: string): Promise<void> => {
const { isBilling } = await getWorkspaceAuth(workspaceId);
if (isBilling) {
redirect(getBillingFallbackPath(workspaceId, IS_FORMBRICKS_CLOUD));
}
};
@@ -1,3 +1,11 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
export default APIKeysPage;
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return APIKeysPage(props);
};
export default Page;
@@ -1,3 +1,18 @@
import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { PricingPage } from "@/modules/ee/billing/page";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
export default PricingPage;
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
const { isBilling } = await getWorkspaceAuth(params.workspaceId);
if (isBilling && !IS_FORMBRICKS_CLOUD) {
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
}
return PricingPage(props);
};
export default Page;
@@ -1,6 +1,7 @@
import { notFound } from "next/navigation";
import { AuthenticationError } from "@formbricks/types/errors";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { PrettyUrlsTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/domain/components/pretty-urls-table";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
@@ -12,8 +13,9 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
const t = await getTranslate();
if (IS_FORMBRICKS_CLOUD) {
@@ -66,11 +66,6 @@ const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
labelKey: t("workspace.settings.general.ai_smart_tools_enabled"),
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
},
{
key: "aiDataAnalysis",
labelKey: t("workspace.settings.general.ai_data_analysis_enabled"),
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
},
{
key: "auditLogs",
labelKey: t("workspace.settings.enterprise.license_feature_audit_logs"),
@@ -112,12 +112,12 @@ export const EnterpriseLicenseStatus = ({
className="shrink-0">
{isRechecking ? (
<>
<RotateCcwIcon className="mr-2 h-4 w-4 animate-spin" />
<RotateCcwIcon className="mr-2 size-4 animate-spin" />
{t("workspace.settings.enterprise.rechecking")}
</>
) : (
<>
<RotateCcwIcon className="mr-2 h-4 w-4" />
<RotateCcwIcon className="mr-2 size-4" />
{t("workspace.settings.enterprise.recheck_license")}
</>
)}
@@ -1,9 +1,10 @@
import { CheckIcon } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { EnterpriseLicenseFeaturesTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseFeaturesTable";
import { EnterpriseLicenseStatus } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseStatus";
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getTranslate } from "@/lingodotdev/server";
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { Button } from "@/modules/ui/components/button";
@@ -11,15 +12,19 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
const t = await getTranslate();
const { isBilling, isMember } = await getWorkspaceAuth(params.workspaceId);
if (isBilling && IS_FORMBRICKS_CLOUD) {
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
}
if (IS_FORMBRICKS_CLOUD) {
return notFound();
}
const { isMember } = await getWorkspaceAuth(params.workspaceId);
const isPricingDisabled = isMember;
if (isPricingDisabled) {
@@ -104,7 +109,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
<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 left-1/2 top-1/2 -z-10 size-[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}
@@ -140,7 +145,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
{paidFeatures.map((feature) => (
<li key={feature.title} className="flex items-center">
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
<CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" />
<CheckIcon className="size-5 p-0.5 text-green-500 dark:text-green-400" />
</div>
<span className="ml-2 text-sm text-slate-500 dark:text-slate-400">{feature.title}</span>
{feature.comingSoon && (
@@ -1 +1,11 @@
export { FeedbackDirectoriesPage as default } from "@/modules/ee/feedback-directory/page";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { FeedbackDirectoriesPage } from "@/modules/ee/feedback-directory/page";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return FeedbackDirectoriesPage(props);
};
export default Page;
@@ -57,7 +57,6 @@ describe("organization AI settings actions", () => {
mocks.getOrganization.mockResolvedValue({
id: organizationId,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
});
mocks.isInstanceAIConfigured.mockReturnValue(true);
mocks.getTranslate.mockResolvedValue((key: string, values?: Record<string, string>) =>
@@ -66,7 +65,6 @@ describe("organization AI settings actions", () => {
mocks.updateOrganization.mockResolvedValue({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.getIsMultiOrgEnabled.mockResolvedValue(true);
});
@@ -114,18 +112,15 @@ describe("organization AI settings actions", () => {
oldObject: {
id: organizationId,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
newObject: {
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
},
});
expect(result).toEqual({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
});
@@ -194,7 +189,6 @@ describe("organization AI settings actions", () => {
mocks.getOrganization.mockResolvedValueOnce({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
@@ -71,12 +71,11 @@ export const updateOrganizationNameAction = authenticatedActionClient
type TOrganizationAISettings = Pick<
NonNullable<Awaited<ReturnType<typeof getOrganization>>>,
"isAISmartToolsEnabled" | "isAIDataAnalysisEnabled"
"isAISmartToolsEnabled"
>;
type TResolvedOrganizationAISettings = {
smartToolsEnabled: boolean;
dataAnalysisEnabled: boolean;
isEnablingAnyAISetting: boolean;
};
@@ -90,16 +89,10 @@ const resolveOrganizationAISettings = ({
const smartToolsEnabled = Object.hasOwn(data, "isAISmartToolsEnabled")
? (data.isAISmartToolsEnabled ?? organization.isAISmartToolsEnabled)
: organization.isAISmartToolsEnabled;
const dataAnalysisEnabled = Object.hasOwn(data, "isAIDataAnalysisEnabled")
? (data.isAIDataAnalysisEnabled ?? organization.isAIDataAnalysisEnabled)
: organization.isAIDataAnalysisEnabled;
return {
smartToolsEnabled,
dataAnalysisEnabled,
isEnablingAnyAISetting:
(smartToolsEnabled && !organization.isAISmartToolsEnabled) ||
(dataAnalysisEnabled && !organization.isAIDataAnalysisEnabled),
isEnablingAnyAISetting: smartToolsEnabled && !organization.isAISmartToolsEnabled,
};
};
@@ -50,29 +50,18 @@ export const AISettingsToggle = ({
currentValue: organization.isAISmartToolsEnabled,
isInstanceConfigured: isInstanceAIConfigured,
});
const displayedDataAnalysisValue = getDisplayedOrganizationAISettingValue({
currentValue: organization.isAIDataAnalysisEnabled,
isInstanceConfigured: isInstanceAIConfigured,
});
const handleToggle = async (
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
checked: boolean
) => {
const handleToggle = async (checked: boolean) => {
if (checked && !aiEnablementState.canEnableFeatures) {
toast.error(aiEnablementBlockedMessage);
return;
}
setLoadingField(field);
setLoadingField("isAISmartToolsEnabled");
try {
const data =
field === "isAISmartToolsEnabled"
? { isAISmartToolsEnabled: checked }
: { isAIDataAnalysisEnabled: checked };
const response = await updateOrganizationAISettingsAction({
organizationId: organization.id,
data,
data: { isAISmartToolsEnabled: checked },
});
if (response?.data) {
@@ -122,7 +111,7 @@ export const AISettingsToggle = ({
<AdvancedOptionToggle
isChecked={displayedSmartToolsValue}
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
onToggle={handleToggle}
htmlId="ai-smart-tools-toggle"
title={t("workspace.settings.general.ai_smart_tools_enabled")}
description={t("workspace.settings.general.ai_smart_tools_enabled_description")}
@@ -130,16 +119,6 @@ export const AISettingsToggle = ({
customContainerClass="px-0"
/>
<AdvancedOptionToggle
isChecked={displayedDataAnalysisValue}
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
htmlId="ai-data-analysis-toggle"
title={t("workspace.settings.general.ai_data_analysis_enabled")}
description={t("workspace.settings.general.ai_data_analysis_enabled_description")}
disabled={isToggleDisabled}
customContainerClass="px-0"
/>
{!canEdit && (
<Alert variant="warning">
<AlertDescription>
@@ -8,8 +8,8 @@ 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" />
<div className="flex items-center gap-x-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm">
<ShieldCheckIcon className="size-5 flex-shrink-0 text-blue-400" />
<p className="text-sm">
{t("workspace.settings.general.security_list_tip")}{" "}
<Link
@@ -1,3 +1,4 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { isInstanceAIConfigured } from "@/lib/ai/service";
import {
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
@@ -8,7 +9,6 @@ import {
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import {
getIsAIDataAnalysisEnabled,
getIsAISmartToolsEnabled,
getIsMultiOrgEnabled,
getWhiteLabelPermission,
@@ -26,8 +26,9 @@ import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
import { SecurityListTip } from "./components/SecurityListTip";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
const t = await getTranslate();
const { session, currentUserMembership, organization, isOwner, isManager } = await getWorkspaceAuth(
@@ -36,14 +37,11 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const user = session?.user?.id ? await getUser(session.user.id) : null;
const [isMultiOrgEnabled, hasWhiteLabelPermission, hasAISmartToolsPermission, hasAIDataAnalysisPermission] =
await Promise.all([
getIsMultiOrgEnabled(),
getWhiteLabelPermission(organization.id),
getIsAISmartToolsEnabled(organization.id),
getIsAIDataAnalysisEnabled(organization.id),
]);
const hasAIPermission = hasAISmartToolsPermission || hasAIDataAnalysisPermission;
const [isMultiOrgEnabled, hasWhiteLabelPermission, hasAIPermission] = await Promise.all([
getIsMultiOrgEnabled(),
getWhiteLabelPermission(organization.id),
getIsAISmartToolsEnabled(organization.id),
]);
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
const currentUserRole = currentUserMembership?.role;
@@ -4,7 +4,6 @@ import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
export const ZOrganizationAISettingsInput = ZOrganizationUpdateInput.pick({
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
});
export const ZUpdateOrganizationAISettingsAction = z.object({
@@ -1,3 +1,11 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { TeamsPage } from "@/modules/organization/settings/teams/page";
export default TeamsPage;
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return TeamsPage(props);
};
export default Page;
@@ -1,7 +1,9 @@
import { redirect } from "next/navigation";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return redirect(`/workspaces/${params.workspaceId}/settings/workspace/general`);
};
@@ -68,7 +68,7 @@ const ElementCheckbox = ({
};
return (
<div className="my-1 flex items-center space-x-2">
<div className="my-1 flex items-center gap-x-2">
<label htmlFor={element.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
@@ -330,7 +330,7 @@ export const AddIntegrationModal = ({
<Dialog open={open} onOpenChange={setOpenWithStates}>
<DialogContent className="overflow-visible md:overflow-visible">
<DialogHeader>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<div className="relative size-8">
<Image
fill
@@ -93,9 +93,9 @@ export const ManageIntegration = ({
</AlertButton>
</Alert>
)}
<div className="flex w-full justify-end space-x-2">
<div className="flex w-full justify-end gap-x-2">
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="mr-4 size-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
{t("workspace.integrations.connected_with_email", {
email: airtableIntegration.config.email,
@@ -106,7 +106,7 @@ export const ManageIntegration = ({
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleAirtableAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
<RefreshCcwIcon className="mr-2 size-4" />
{t("workspace.integrations.reconnect_button")}
</Button>
</TooltipTrigger>
@@ -134,6 +134,7 @@ export const ManageIntegration = ({
{integrationData.map((data, index) => (
<button
type="button"
key={`${index}-${data.baseId}-${data.tableId}-${data.surveyId}`}
className="grid h-16 w-full grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
onClick={() => {
@@ -247,7 +247,7 @@ export const AddIntegrationModal = ({
<Dialog open={open} onOpenChange={setOpenWithStates}>
<DialogContent>
<DialogHeader>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<div className="relative size-8">
<Image
fill
@@ -297,7 +297,7 @@ export const AddIntegrationModal = ({
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden 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">
<div key={question.id} className="my-1 flex items-center gap-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
@@ -84,9 +84,9 @@ export const ManageIntegration = ({
</AlertButton>
</Alert>
)}
<div className="flex w-full justify-end space-x-2">
<div className="flex w-full justify-end gap-x-2">
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="mr-4 size-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
{t("workspace.integrations.connected_with_email", {
email: googleSheetIntegration.config.email,
@@ -97,7 +97,7 @@ export const ManageIntegration = ({
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleGoogleAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
<RefreshCcwIcon className="mr-2 size-4" />
{t("workspace.integrations.google_sheets.reconnect_button")}
</Button>
</TooltipTrigger>
@@ -132,6 +132,7 @@ export const ManageIntegration = ({
{integrationArray.map((data, index) => {
return (
<button
type="button"
key={`${index}-${data.spreadsheetName}-${data.surveyName}`}
className="grid h-16 w-full cursor-pointer grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
onClick={() => {
@@ -277,7 +277,7 @@ export const AddIntegrationModal = ({
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<div className="mb-4 flex items-start space-x-2">
<div className="mb-4 flex items-start gap-x-2">
<div className="relative size-8">
<Image
fill
@@ -68,9 +68,9 @@ export const ManageIntegration = ({
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
<div className="flex w-full justify-end space-x-2">
<div className="flex w-full justify-end gap-x-2">
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="mr-4 size-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
{t("workspace.integrations.notion.connected_with_workspace", {
workspace: notionIntegration.config.key.workspace_name,
@@ -81,7 +81,7 @@ export const ManageIntegration = ({
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleNotionAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
<RefreshCcwIcon className="mr-2 size-4" />
{t("workspace.integrations.notion.update_connection")}
</Button>
</TooltipTrigger>
@@ -113,6 +113,7 @@ export const ManageIntegration = ({
{integrationArray.map((data, index) => {
return (
<button
type="button"
key={`${index}-${data.databaseId}`}
className="grid h-16 w-full cursor-pointer grid-cols-6 content-center rounded-lg p-2 hover:bg-slate-100"
onClick={() => {
@@ -190,7 +190,7 @@ export const MappingRow = ({
elem={mapping[idx].element}
t={t}
/>
<div className="flex w-full items-center space-x-2">
<div className="flex w-full items-center gap-x-2">
<div className="flex w-full items-center">
<div className="max-w-full flex-1">
<DropdownSelector
@@ -212,7 +212,7 @@ export const MappingRow = ({
/>
</div>
</div>
<div className="flex space-x-2">
<div className="flex gap-x-2">
{mapping.length > 1 && (
<Button variant="secondary" size="icon" className="size-10" onClick={deleteRow} type="button">
<TrashIcon />
@@ -221,7 +221,7 @@ export const AddChannelMappingModal = ({
<Dialog open={open} onOpenChange={setOpenWithStates}>
<DialogContent>
<DialogHeader>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<div className="relative size-8">
<Image
fill
@@ -255,7 +255,7 @@ export const AddChannelMappingModal = ({
target="_blank"
className="text-xs">
<Button variant="ghost" size="sm" className="my-2" type="button">
<CircleHelpIcon className="h-4 w-4" />
<CircleHelpIcon className="size-4" />
{t("workspace.integrations.slack.dont_see_your_channel")}
</Button>
</Link>
@@ -290,7 +290,7 @@ export const AddChannelMappingModal = ({
<div className="mt-1 max-h-[15vh] 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((element) => (
<div key={element.id} className="my-1 flex items-center space-x-2">
<div key={element.id} className="my-1 flex items-center gap-x-2">
<label htmlFor={element.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
@@ -71,7 +71,7 @@ export const ManageIntegration = ({
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
{showReconnectButton && (
<div className="mb-4 flex w-full items-center justify-between space-x-4">
<div className="mb-4 flex w-full items-center justify-between gap-x-4">
<p className="text-amber-700">
<Trans
i18nKey="workspace.integrations.slack.slack_reconnect_button_description"
@@ -83,9 +83,9 @@ export const ManageIntegration = ({
</Button>
</div>
)}
<div className="flex w-full justify-end space-x-4">
<div className="flex w-full justify-end gap-x-4">
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="mr-4 size-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
{t("workspace.integrations.slack.connected_with_team", {
team: slackIntegration.config.key.team?.name,
@@ -119,6 +119,7 @@ export const ManageIntegration = ({
{integrationArray.map((data, index) => {
return (
<button
type="button"
key={`${index}-${data.surveyName}-${data.channelName}`}
className="grid h-16 w-full grid-cols-8 content-center rounded-lg p-2 text-slate-700 hover:cursor-pointer hover:bg-slate-100"
onClick={() => {
@@ -11,7 +11,7 @@ export const EmptyAppSurveys = () => {
const { workspace } = useWorkspace();
return (
<div className="flex w-full items-center justify-center gap-8 bg-slate-100 py-12">
<div className="flex h-20 w-20 items-center justify-center rounded-full border border-slate-200 bg-white">
<div className="flex size-20 items-center justify-center rounded-full border border-slate-200 bg-white">
<Unplug size={48} className="text-amber-500" absoluteStrokeWidth />
</div>
@@ -24,7 +24,7 @@ export const SurveyAnalysisNavigation = ({ survey, activeId }: SurveyAnalysisNav
{
id: "summary",
label: t("common.summary"),
icon: <PresentationIcon className="h-5 w-5" />,
icon: <PresentationIcon className="size-5" />,
href: `${url}/summary?referer=true`,
current: pathname?.includes("/summary"),
onClick: () => {
@@ -34,7 +34,7 @@ export const SurveyAnalysisNavigation = ({ survey, activeId }: SurveyAnalysisNav
{
id: "responses",
label: t("common.responses"),
icon: <InboxIcon className="h-5 w-5" />,
icon: <InboxIcon className="size-5" />,
href: `${url}/responses?referer=true`,
current: pathname?.includes("/responses"),
onClick: () => {
@@ -38,7 +38,7 @@ const ResponseTableCellComponent = ({
aria-label="Expand response"
className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 hover:border-slate-300 focus:outline-none group-hover:flex"
onClick={handleCellClick}>
<Maximize2Icon className="h-4 w-4" />
<Maximize2Icon className="size-4" />
</button>
);
@@ -47,8 +47,8 @@ const getElementColumnsData = (
const title = suffix ? `${headline} - ${suffix}` : headline;
const ElementHeader = () => (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{ELEMENTS_ICON_MAP[elementType]}</span>
<div className="flex items-center gap-x-2 overflow-hidden">
<span className="size-4">{ELEMENTS_ICON_MAP[elementType]}</span>
<span className="truncate">{title}</span>
</div>
</div>
@@ -85,8 +85,8 @@ const getElementColumnsData = (
header: () => {
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{ELEMENTS_ICON_MAP["matrix"]}</span>
<div className="flex items-center gap-x-2 overflow-hidden">
<span className="size-4">{ELEMENTS_ICON_MAP["matrix"]}</span>
<span className="truncate">
{getTextContent(getLocalizedValue(element.headline, "default")) +
" - " +
@@ -112,8 +112,8 @@ const getElementColumnsData = (
header: () => {
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{ELEMENTS_ICON_MAP["address"]}</span>
<div className="flex items-center gap-x-2 overflow-hidden">
<span className="size-4">{ELEMENTS_ICON_MAP["address"]}</span>
<span className="truncate">{getAddressFieldLabel(addressField, t)}</span>
</div>
</div>
@@ -135,8 +135,8 @@ const getElementColumnsData = (
header: () => {
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{ELEMENTS_ICON_MAP["contactInfo"]}</span>
<div className="flex items-center gap-x-2 overflow-hidden">
<span className="size-4">{ELEMENTS_ICON_MAP["contactInfo"]}</span>
<span className="truncate">{getContactInfoFieldLabel(contactInfoField, t)}</span>
</div>
</div>
@@ -202,8 +202,8 @@ const getElementColumnsData = (
accessorKey: "ELEMENT_" + element.id,
header: () => (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{ELEMENTS_ICON_MAP[element.type]}</span>
<div className="flex items-center gap-x-2 overflow-hidden">
<span className="size-4">{ELEMENTS_ICON_MAP[element.type]}</span>
<span className="truncate">
{getTextContent(
getLocalizedValue(recallToHeadline(element.headline, survey, false, "default"), "default")
@@ -241,8 +241,8 @@ const getMetadataColumnsData = (t: TFunction): ColumnDef<TResponseTableData>[] =
metadataColumns.push({
accessorKey: "METADATA_" + label,
header: () => (
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{IconComponent && <IconComponent className="h-4 w-4" />}</span>
<div className="flex items-center gap-x-2 overflow-hidden">
<span className="size-4">{IconComponent && <IconComponent className="h-4 w-4" />}</span>
<span className="truncate">{getMetadataFieldLabel(label, t)}</span>
</div>
),
@@ -290,7 +290,7 @@ export const generateResponseTableColumns = (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger>
<CircleHelpIcon className="h-3 w-3 text-slate-500" strokeWidth={1.5} />
<CircleHelpIcon className="size-3 text-slate-500" strokeWidth={1.5} />
</TooltipTrigger>
<TooltipContent side="bottom" className="space-x-1 font-normal">
<span>{t("workspace.surveys.responses.how_to_identify_users")}</span>
@@ -363,7 +363,7 @@ export const generateResponseTableColumns = (
<ResponseBadges
items={tagsArray.map((tag) => ({ value: tag }))}
isExpanded={isExpanded}
icon={<TagIcon className="h-4 w-4 text-slate-500" />}
icon={<TagIcon className="size-4 text-slate-500" />}
showId={false}
/>
);
@@ -375,8 +375,8 @@ export const generateResponseTableColumns = (
return {
accessorKey: "VARIABLE_" + variable.id,
header: () => (
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{VARIABLES_ICON_MAP[variable.type]}</span>
<div className="flex items-center gap-x-2 overflow-hidden">
<span className="size-4">{VARIABLES_ICON_MAP[variable.type]}</span>
<span className="truncate">{variable.name}</span>
</div>
),
@@ -394,9 +394,9 @@ export const generateResponseTableColumns = (
return {
accessorKey: "HIDDEN_FIELD_" + hiddenFieldId,
header: () => (
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">
<EyeOffIcon className="h-4 w-4" />
<div className="flex items-center gap-x-2 overflow-hidden">
<span className="size-4">
<EyeOffIcon className="size-4" />
</span>
<span className="truncate">{hiddenFieldId}</span>
</div>
@@ -416,9 +416,9 @@ export const generateResponseTableColumns = (
const verifiedEmailColumn: ColumnDef<TResponseTableData> = {
accessorKey: "verifiedEmail",
header: () => (
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">
<MailIcon className="h-4 w-4" />
<div className="flex items-center gap-x-2 overflow-hidden">
<span className="size-4">
<MailIcon className="size-4" />
</span>
<span className="truncate">{t("common.verified_email")}</span>
</div>
@@ -2,7 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEmailTemplateHtml } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { capturePostHogEvent } from "@/lib/posthog";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
@@ -176,7 +176,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
);
if (!contactsResult || contactsResult.length === 0) {
throw new UnknownError("No contacts found for the selected segment");
throw new InvalidInputError("No contacts found for the selected segment");
}
capturePostHogEvent(
@@ -25,9 +25,9 @@ export const CESSummary = ({ elementSummary, survey, setFilter }: CESSummaryProp
const getIconBasedOnScale = useMemo(() => {
const scale = elementSummary.element.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
if (scale === "number") return <CircleSlash2 className="size-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="size-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="size-4" />;
}, [elementSummary.element.scale]);
return (
@@ -36,8 +36,8 @@ export const CESSummary = ({ elementSummary, survey, setFilter }: CESSummaryProp
survey={survey}
setFilter={setFilter}
additionalInfo={
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<div className="flex items-center gap-x-2">
<div className="flex items-center gap-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("workspace.surveys.summary.effort_score")}: {elementSummary.average.toFixed(2)} /{" "}
@@ -27,9 +27,9 @@ export const CSATSummary = ({ elementSummary, survey, setFilter }: CSATSummaryPr
const getIconBasedOnScale = useMemo(() => {
const scale = elementSummary.element.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
if (scale === "number") return <CircleSlash2 className="size-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="size-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="size-4" />;
}, [elementSummary.element.scale]);
return (
@@ -38,8 +38,8 @@ export const CSATSummary = ({ elementSummary, survey, setFilter }: CSATSummaryPr
survey={survey}
setFilter={setFilter}
additionalInfo={
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<div className="flex items-center gap-x-2">
<div className="flex items-center gap-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("workspace.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
@@ -49,7 +49,7 @@ export const CSATSummary = ({ elementSummary, survey, setFilter }: CSATSummaryPr
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<div className="flex items-center gap-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.csat.satisfiedPercentage} />
<div>
{t("workspace.surveys.summary.csat_satisfied", {
@@ -24,16 +24,16 @@ export const CTASummary = ({ elementSummary, survey }: CTASummaryProps) => {
additionalInfo={
<>
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
<InboxIcon className="mr-2 size-4" />
{`${elementSummary.impressionCount} ${t("common.impressions")}`}
</div>
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
<InboxIcon className="mr-2 size-4" />
{`${elementSummary.clickCount} ${t("common.clicks")}`}
</div>
{!elementSummary.element.required && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
<InboxIcon className="mr-2 size-4" />
{`${elementSummary.skipCount} ${t("common.skips")}`}
</div>
)}
@@ -42,7 +42,7 @@ export const CTASummary = ({ elementSummary, survey }: CTASummaryProps) => {
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">
<div className="mr-8 flex gap-x-1">
<p className="font-semibold text-slate-700">CTR</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
@@ -20,7 +20,7 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">
<div className="mr-8 flex gap-x-1">
<p className="font-semibold text-slate-700">{t("common.booked")}</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
@@ -36,7 +36,7 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
</div>
<div>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">
<div className="mr-8 flex gap-x-1">
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
@@ -22,7 +22,7 @@ export const ClickableBarSegment = ({
return (
<Tooltip>
<TooltipTrigger asChild>
<button className={className} style={style} onClick={onClick}>
<button type="button" className={className} style={style} onClick={onClick}>
{children}
</button>
</TooltipTrigger>
@@ -41,6 +41,7 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
{summaryItems.map((summaryItem) => {
return (
<button
type="button"
className="group w-full cursor-pointer"
key={summaryItem.title}
onClick={() =>
@@ -53,7 +54,7 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
)
}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">
<div className="mr-8 flex gap-x-1">
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
{summaryItem.title}
</p>
@@ -39,15 +39,15 @@ export const ElementSummaryHeader = ({
)}
</h3>
</div>
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="flex gap-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="flex items-center rounded-lg bg-slate-100 p-2">
{elementType && <elementType.icon className="mr-2 h-4 w-4" />}
{elementType && <elementType.icon className="mr-2 size-4" />}
{elementType ? elementType.label : t("workspace.surveys.summary.unknown_question_type")}{" "}
{t("common.question")}
</div>
{showResponses && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
<InboxIcon className="mr-2 size-4" />
{t("common.count_responses", { count: elementSummary.responseCount })}
</div>
)}
@@ -83,7 +83,7 @@ export const FileUploadSummary = ({ elementSummary, survey, locale }: FileUpload
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer">
<div className="absolute right-0 top-0 m-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
<div className="flex size-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
<DownloadIcon className="h-6 text-slate-500" />
</div>
</div>
@@ -34,13 +34,13 @@ export const HiddenFieldsSummary = ({ elementSummary, locale }: HiddenFieldsSumm
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{elementSummary.id}</h3>
</div>
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="flex gap-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<MessageSquareTextIcon className="mr-2 h-4 w-4" />
<MessageSquareTextIcon className="mr-2 size-4" />
Hidden Field
</div>
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
<InboxIcon className="mr-2 size-4" />
{t("common.count_responses", { count: elementSummary.responseCount })}
</div>
</div>
@@ -76,6 +76,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
elementSummary.data[rowIndex].totalResponsesForRow
)}>
<button
type="button"
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
className="m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline hover:outline-brand-dark"
onClick={() =>
@@ -74,7 +74,7 @@ export const MultipleChoiceSummary = ({
additionalInfo={
elementSummary.type === "multipleChoiceMulti" ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
<InboxIcon className="mr-2 size-4" />
{t("common.count_selections", { count: elementSummary.selectionCount })}
</div>
) : undefined
@@ -102,13 +102,13 @@ export const MultipleChoiceSummary = ({
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<div className="mr-8 flex w-full justify-between gap-x-2 sm:justify-normal">
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
{result.value}
</p>
{choiceId && <IdBadge id={choiceId} />}
</div>
<div className="flex w-full space-x-2">
<div className="flex w-full gap-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{t("common.count_selections", { count: result.count })}
</p>
@@ -150,7 +150,7 @@ export const MultipleChoiceSummary = ({
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
<div className="ph-no-capture col-span-1 flex items-center gap-x-4 pl-6 font-medium text-slate-900">
{otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
<span>
{getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
@@ -85,7 +85,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<div className="flex items-center gap-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.promoters.percentage} />
<div>
{t("workspace.surveys.summary.promoters")}: {promotersPercentage}%
@@ -105,10 +105,10 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="size-4" />}>
{t("workspace.surveys.summary.aggregated")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
<TabsTrigger value="individual" icon={<BarChart className="size-4" />}>
{t("workspace.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
@@ -119,12 +119,13 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
<div className="space-y-5 text-sm md:text-base">
{(["promoters", "passives", "detractors", "dismissed"] as const).map((group) => (
<button
type="button"
className="w-full cursor-pointer hover:opacity-80"
key={group}
onClick={() => applyFilter(group)}>
<div
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex space-x-1">
<div className="mr-8 flex gap-x-1">
<p
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group}
@@ -179,7 +180,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
</div>
<div className="flex w-full flex-col items-center rounded-b-lg border border-t-0 border-slate-200 bg-slate-50 px-1 py-2">
<div className="mb-1.5 text-xs font-medium text-slate-500">{choice.rating}</div>
<div className="mb-1 flex items-center space-x-1">
<div className="mb-1 flex items-center gap-x-1">
<div className="text-base font-semibold text-slate-700">{choice.count}</div>
<div className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600">
{convertFloatToNDecimal(choice.percentage, 1)}%
@@ -36,7 +36,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
additionalInfo={
elementSummary.element.allowMulti ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
<InboxIcon className="mr-2 size-4" />
{t("common.count_selections", { count: elementSummary.selectionCount })}
</div>
) : undefined
@@ -60,7 +60,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<div className="mr-8 flex w-full justify-between gap-x-2 sm:justify-normal">
<div className="relative h-32 w-[220px]">
<Image
src={result.imageUrl}
@@ -72,7 +72,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
</div>
<div className="self-end">{choiceId && <IdBadge id={choiceId} />}</div>
</div>
<div className="flex w-full space-x-2">
<div className="flex w-full gap-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{t("common.count_selections", { count: result.count })}
</p>
@@ -26,14 +26,14 @@ export const RankingSummary = ({ elementSummary, survey }: RankingSummaryProps)
return (
<div key={result.value} className="group cursor-pointer">
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<div className="mr-8 flex w-full justify-between gap-x-2 sm:justify-normal">
<div className="flex w-full items-center">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<span className="mr-2 text-slate-400">#{resultsIdx + 1}</span>
<div className="rounded bg-slate-100 px-2 py-1">{result.value}</div>
{choiceId && <IdBadge id={choiceId} />}
</div>
<span className="ml-auto flex items-center space-x-1">
<span className="ml-auto flex items-center gap-x-1">
<span className="font-bold text-slate-600">
#{convertFloatToNDecimal(result.avgRanking, 2)}
</span>
@@ -55,10 +55,10 @@ export const RatingLikeSummary = ({
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="size-4" />}>
{t("workspace.surveys.summary.aggregated")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
<TabsTrigger value="individual" icon={<BarChart className="size-4" />}>
{t("workspace.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
@@ -158,6 +158,7 @@ export const RatingLikeSummary = ({
{elementSummary.choices.map((result) => (
<div key={result.rating}>
<button
type="button"
className="w-full cursor-pointer hover:opacity-80"
onClick={() =>
setFilter(
@@ -169,7 +170,7 @@ export const RatingLikeSummary = ({
)
}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex items-center space-x-1">
<div className="mr-8 flex items-center gap-x-1">
<div className="font-semibold text-slate-700">
<RatingResponse
scale={elementSummary.element.scale}
@@ -11,11 +11,11 @@ interface RatingScaleLegendProps {
export const RatingScaleLegend = ({ scale, range }: RatingScaleLegendProps) => {
return (
<div className="mt-3 flex w-full items-start justify-between px-1">
<div className="flex items-center space-x-1">
<div className="flex items-center gap-x-1">
<RatingResponse scale={scale} answer={1} range={range} addColors={false} variant="scale" />
<span className="text-xs text-slate-500">1</span>
</div>
<div className="flex items-center space-x-1">
<div className="flex items-center gap-x-1">
<span className="text-xs text-slate-500">{range}</span>
<RatingResponse scale={scale} answer={range} range={range} addColors={false} variant="scale" />
</div>
@@ -25,9 +25,9 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
const getIconBasedOnScale = useMemo(() => {
const scale = elementSummary.element.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
if (scale === "number") return <CircleSlash2 className="size-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="size-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="size-4" />;
}, [elementSummary.element.scale]);
return (
@@ -36,8 +36,8 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
survey={survey}
setFilter={setFilter}
additionalInfo={
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<div className="flex items-center gap-x-2">
<div className="flex items-center gap-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("workspace.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
@@ -42,6 +42,7 @@ const ScrollToTop: React.FC<ScrollToTopProps> = ({ containerId }) => {
return (
<button
type="button"
onClick={scrollToTop}
className={`fixed bottom-4 right-4 z-[1] flex h-10 w-10 justify-center rounded-md bg-slate-500 p-2 text-white transition-opacity ${
showButton ? "opacity-80" : "opacity-0"
@@ -18,7 +18,7 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
const { t } = useTranslation();
const getIcon = (elementType: TSurveyElementTypeEnum) => {
const Icon = getElementIcon(elementType, t);
return <Icon className="mt-[3px] h-5 w-5 shrink-0 text-slate-600" />;
return <Icon className="mt-[3px] size-5 shrink-0 text-slate-600" />;
};
return (
@@ -30,7 +30,7 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<TimerIcon className="h-5 w-5" />
<TimerIcon className="size-5" />
</TooltipTrigger>
<TooltipContent side={"top"}>
<p className="text-center font-normal">{t("workspace.surveys.summary.ttc_tooltip")}</p>
@@ -42,7 +42,7 @@ export const SummaryImpressions = ({
<div className="p-8">
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex items-center gap-2 text-red-600">
<AlertCircleIcon className="h-5 w-5" />
<AlertCircleIcon className="size-5" />
<span className="text-sm font-medium">{t("common.error_loading_data")}</span>
</div>
<p className="text-sm text-slate-500">{displaysError}</p>
@@ -116,7 +116,7 @@ export const SummaryImpressions = ({
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex items-center gap-2 rounded-t-xl border-b border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<InfoIcon className="h-4 w-4 shrink-0" />
<InfoIcon className="size-4 shrink-0" />
<span>{t("workspace.surveys.summary.impressions_identified_only")}</span>
</div>
{renderContent()}
@@ -38,8 +38,8 @@ export const InteractiveCard = ({
{isLoading ? <div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div> : value}
</span>
{!isLoading && (
<div className="flex h-6 w-6 items-center justify-center rounded-md border border-slate-200 bg-slate-50 hover:bg-slate-100">
{isActive ? <ChevronUpIcon className="h-4 w-4" /> : <ChevronDownIcon className="h-4 w-4" />}
<div className="flex size-6 items-center justify-center rounded-md border border-slate-200 bg-slate-50 hover:bg-slate-100">
{isActive ? <ChevronUpIcon className="size-4" /> : <ChevronDownIcon className="h-4 w-4" />}
</div>
)}
</div>
@@ -246,7 +246,7 @@ export const AnonymousLinksTab = ({
return (
<>
<div className="flex h-full flex-col justify-between space-y-4">
<div className="flex h-full flex-col justify-between gap-y-4">
<div className="flex w-full grow flex-col gap-6">
<AdvancedOptionToggle
htmlId="multi-use-link-switch"
@@ -354,7 +354,7 @@ export const AnonymousLinksTab = ({
onClick={() => handleGenerateLinks(Number(numberOfLinks) || 1)}
disabled={Number(numberOfLinks) < 1 || Number(numberOfLinks) > 5000}>
<div className="flex items-center gap-2">
<CirclePlayIcon className="h-3.5 w-3.5 shrink-0 text-slate-50" />
<CirclePlayIcon className="size-3.5 shrink-0 text-slate-50" />
</div>
<span className="text-sm text-slate-50">
@@ -149,8 +149,8 @@ export const AppTab = () => {
};
return (
<div className="flex flex-col justify-between space-y-6 pb-4">
<div className="flex flex-col space-y-6">
<div className="flex flex-col justify-between gap-y-6 pb-4">
<div className="flex flex-col gap-y-6">
<Alert variant={workspace.appSetupCompleted ? "success" : "warning"} size="default">
<AlertTitle>
{workspace.appSetupCompleted
@@ -171,14 +171,14 @@ export const AppTab = () => {
)}
</Alert>
<div className="flex flex-col space-y-3">
<div className="flex flex-col gap-y-3">
<H4>{t("workspace.surveys.summary.in_app.display_criteria")}</H4>
<div
className={
"flex w-full flex-col space-y-4 rounded-xl border border-slate-200 bg-white p-3 text-left shadow-sm"
}>
<DisplayCriteriaItem
icon={<TimerResetIcon className="h-4 w-4" />}
icon={<TimerResetIcon className="size-4" />}
title={waitTime()}
titleSuffix={
survey.recontactDays !== null
@@ -188,7 +188,7 @@ export const AppTab = () => {
description={t("workspace.surveys.summary.in_app.display_criteria.time_based_description")}
/>
<DisplayCriteriaItem
icon={<UsersIcon className="h-4 w-4" />}
icon={<UsersIcon className="size-4" />}
title={getSegmentTitle(survey.segment)}
description={t("workspace.surveys.summary.in_app.display_criteria.audience_description")}
/>
@@ -197,9 +197,9 @@ export const AppTab = () => {
key={trigger.actionClass.id}
icon={
trigger.actionClass.type === "code" ? (
<CodeXmlIcon className="h-4 w-4" />
<CodeXmlIcon className="size-4" />
) : (
<MousePointerClickIcon className="h-4 w-4" />
<MousePointerClickIcon className="size-4" />
)
}
title={trigger.actionClass.name}
@@ -209,7 +209,7 @@ export const AppTab = () => {
))}
{survey.displayPercentage !== null && survey.displayPercentage > 0 && (
<DisplayCriteriaItem
icon={<PercentIcon className="h-4 w-4" />}
icon={<PercentIcon className="size-4" />}
title={t("workspace.surveys.summary.in_app.display_criteria.randomizer", {
percentage: survey.displayPercentage,
})}
@@ -219,7 +219,7 @@ export const AppTab = () => {
/>
)}
<DisplayCriteriaItem
icon={<Repeat1Icon className="h-4 w-4" />}
icon={<Repeat1Icon className="size-4" />}
title={displayOption()}
description={t("workspace.surveys.summary.in_app.display_criteria.recontact_description")}
/>
@@ -151,7 +151,7 @@ export const CustomHtmlTab = ({ workspaceCustomScripts, isReadOnly }: CustomHtml
</Button>
{/* Security Warning */}
<Alert variant="warning" className="flex items-start gap-2">
<AlertTriangleIcon className="mt-0.5 h-4 w-4 shrink-0" />
<AlertTriangleIcon className="mt-0.5 size-4 shrink-0" />
<AlertDescription>{t("workspace.surveys.share.custom_html.security_warning")}</AlertDescription>
</Alert>
</form>
@@ -20,7 +20,7 @@ export const DocumentationLinksSection = ({ title, links }: DocumentationLinksSe
const { t } = useTranslation();
return (
<div className="flex w-full flex-col space-y-3">
<div className="flex w-full flex-col gap-y-3">
<H4>{title}</H4>
{links.map((link) => (
<Alert key={link.title} size="small" variant="default">
@@ -15,7 +15,7 @@ export const DocumentationLinks = ({ links }: DocumentationLinksProps) => {
const { t } = useTranslation();
return (
<div className="flex w-full flex-col space-y-2">
<div className="flex w-full flex-col gap-y-2">
{links.map((link) => (
<div key={link.title} className="flex w-full flex-col gap-3">
<Alert variant="outbound" size="small">
@@ -15,7 +15,7 @@ export const DynamicPopupTab = ({ surveyId }: DynamicPopupTabProps) => {
const { workspace } = useWorkspace();
return (
<div className="flex h-full flex-col justify-between space-y-4" data-testid="dynamic-popup-container">
<div className="flex h-full flex-col justify-between gap-y-4" data-testid="dynamic-popup-container">
<Alert variant="info" size="default">
<AlertTitle>{t("workspace.surveys.share.dynamic_popup.alert_title")}</AlertTitle>
<AlertDescription>{t("workspace.surveys.share.dynamic_popup.alert_description")}</AlertDescription>
@@ -131,9 +131,9 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4"
data-testid="survey-email-preview-shell">
<div className="mb-6 flex gap-2">
<div className="h-3 w-3 rounded-full bg-red-500" />
<div className="h-3 w-3 rounded-full bg-amber-500" />
<div className="h-3 w-3 rounded-full bg-emerald-500" />
<div className="size-3 rounded-full bg-red-500" />
<div className="size-3 rounded-full bg-amber-500" />
<div className="size-3 rounded-full bg-emerald-500" />
</div>
<div>
<div className="mb-2 border-b border-slate-200 pb-2 text-sm">
@@ -205,7 +205,7 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
};
return (
<div className="flex h-full w-full flex-col space-y-4">
<div className="flex h-full w-full flex-col gap-y-4">
<TabBar
tabs={tabs}
activeId={activeTab}
@@ -184,7 +184,7 @@ export const PersonalLinksTab = ({
}
return (
<div className="flex h-full flex-col justify-between space-y-4">
<div className="flex h-full flex-col justify-between gap-y-4">
<FormProvider {...form}>
<div className="flex grow flex-col gap-6">
{/* Recipients Section */}
@@ -247,7 +247,7 @@ export const PersonalLinksTab = ({
disabled={isButtonDisabled}
loading={isGenerating}
className="w-fit">
<DownloadIcon className="mr-2 h-4 w-4" />
<DownloadIcon className="mr-2 size-4" />
{buttonText}
</Button>
</div>
@@ -169,7 +169,7 @@ export const PrettyUrlTab = ({ publicDomain, isReadOnly = false }: PrettyUrlTabP
{survey.slug && !isEditing && (
<>
<Button type="button" variant="default" onClick={handleCopyUrl} disabled={isReadOnly}>
<Copy className="mr-2 h-4 w-4" />
<Copy className="mr-2 size-4" />
{t("common.copy")} URL
</Button>
<Button
@@ -177,7 +177,7 @@ export const PrettyUrlTab = ({ publicDomain, isReadOnly = false }: PrettyUrlTabP
variant="destructive"
onClick={() => setShowRemoveDialog(true)}
disabled={isReadOnly}>
<Trash2 className="mr-2 h-4 w-4" />
<Trash2 className="mr-2 size-4" />
{t("common.remove")}
</Button>
</>
@@ -77,7 +77,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
<>
{isLoading && (
<div className="flex flex-col items-center gap-2">
<LoaderCircle className="h-8 w-8 animate-spin text-slate-500" />
<LoaderCircle className="size-8 animate-spin text-slate-500" />
<p className="text-sm text-slate-500">{t("workspace.surveys.summary.generating_qr_code")}</p>
</div>
)}
@@ -91,7 +91,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
{!isLoading && !hasError && (
<div className="flex flex-col items-start justify-center gap-4">
<div className="flex h-[184px] w-[184px] items-center justify-center overflow-hidden rounded-lg border bg-white">
<div className="flex size-[184px] items-center justify-center overflow-hidden rounded-lg border bg-white">
<div ref={qrCodeRef} className="h-full w-full" />
</div>
<Button
@@ -103,9 +103,9 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
? t("workspace.surveys.summary.downloading_qr_code")
: t("workspace.surveys.summary.download_qr_code")}
{isDownloading ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
<LoaderCircle className="size-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
<Download className="size-4" />
)}
</Button>
</div>
@@ -117,7 +117,7 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
tooltip={tab.label}
isActive={tab.id === activeId}
disabled={tab.disabled}>
<tab.icon className="h-4 w-4 text-slate-700" />
<tab.icon className="size-4 text-slate-700" />
<span>{tab.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -145,7 +145,7 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
? "bg-white text-slate-900 shadow-sm hover:bg-white"
: "border-transparent text-slate-700 hover:text-slate-900"
)}>
<tab.icon className="h-4 w-4 text-slate-700" />
<tab.icon className="size-4 text-slate-700" />
</Button>
</TooltipRenderer>
))}
@@ -62,27 +62,27 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
type="button"
onClick={() => handleViewChange("share")}
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">
<Share2Icon className="h-8 w-8 stroke-1 text-slate-900" />
<Share2Icon className="size-8 stroke-1 text-slate-900" />
{t("workspace.surveys.summary.share_survey")}
</button>
<button
type="button"
onClick={() => handleEmbedViewWithTab(tabs[1].id)}
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
<UserIcon className="size-8 stroke-1 text-slate-900" />
{t("workspace.surveys.summary.use_personal_links")}
<Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} />
</button>
<Link
href={`/workspaces/${workspace?.id}/settings/account/notifications`}
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">
<BellRing className="h-8 w-8 stroke-1 text-slate-900" />
<BellRing className="size-8 stroke-1 text-slate-900" />
{t("workspace.surveys.summary.configure_alerts")}
</Link>
<Link
href={`/workspaces/${workspace?.id}/settings/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" />
<BlocksIcon className="size-8 stroke-1 text-slate-900" />
{t("workspace.surveys.summary.setup_integrations")}
</Link>
</div>
@@ -8,7 +8,7 @@ interface TabContainerProps {
export const TabContainer = ({ title, description, children }: TabContainerProps) => {
return (
<div className="flex h-full grow flex-col items-start space-y-4">
<div className="flex h-full grow flex-col items-start gap-y-4">
<div className="pb-2">
<H3>{title}</H3>
<Small color="muted" margin="headerDescription">
@@ -389,7 +389,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
<PopoverTriggerButton isOpen={isDownloadDropDownOpen} disabled={isDownloading}>
<span className="flex items-center gap-2">
{t("common.download")}
{isDownloading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isDownloading && <Loader2 className="size-3 animate-spin" strokeWidth={1.5} />}
</span>
</PopoverTriggerButton>
</DropdownMenuTrigger>
@@ -158,7 +158,7 @@ export const ElementFilterComboBox = ({
) : (
<p className="text-slate-400">{t("common.select")}...</p>
)}
{filterOptions.length > 1 && <ChevronIcon className="h-4 w-4 flex-shrink-0 opacity-50" />}
{filterOptions.length > 1 && <ChevronIcon className="size-4 flex-shrink-0 opacity-50" />}
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-white">
{filterOptions.map((o, index) => {
@@ -203,7 +203,7 @@ export const ElementFilterComboBox = ({
onClick={(e) => handleRemoveTag(e, value)}
className="flex items-center gap-1 whitespace-nowrap rounded bg-slate-100 px-2 py-1 text-sm text-slate-600 hover:bg-slate-200">
{value}
<X className="h-3 w-3" />
<X className="size-3" />
</button>
);
@@ -11,6 +11,7 @@ import {
ContactIcon,
EyeOff,
FlagIcon,
GaugeIcon,
GlobeIcon,
GridIcon,
HashIcon,
@@ -26,6 +27,7 @@ import {
PieChartIcon,
Rows3Icon,
SmartphoneIcon,
SmilePlusIcon,
StarIcon,
User,
} from "lucide-react";
@@ -103,6 +105,8 @@ const elementIcons = {
[TSurveyElementTypeEnum.PictureSelection]: ImageIcon,
[TSurveyElementTypeEnum.Matrix]: GridIcon,
[TSurveyElementTypeEnum.Ranking]: ListOrderedIcon,
[TSurveyElementTypeEnum.CSAT]: SmilePlusIcon,
[TSurveyElementTypeEnum.CES]: GaugeIcon,
[TSurveyElementTypeEnum.Address]: HomeIcon,
[TSurveyElementTypeEnum.ContactInfo]: ContactIcon,
@@ -136,7 +140,7 @@ const getIcon = (type: string) => {
const IconComponent = (elementIcons as Record<string, (typeof elementIcons)[keyof typeof elementIcons]>)[
type
];
return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null;
return IconComponent ? <IconComponent className="size-5" strokeWidth={1.5} /> : null;
};
const getIconBackground = (type: OptionsType | string): string => {
@@ -227,7 +231,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
className="flex-shrink-0"
aria-expanded={open}
aria-label={t("common.select")}>
<ChevronIcon className="h-4 w-4 opacity-50" />
<ChevronIcon className="size-4 opacity-50" />
</Button>
</div>
@@ -57,9 +57,9 @@ export const PopoverTriggerButton = React.forwardRef<HTMLButtonElement, PopoverT
<span className="text-sm text-slate-700">{children}</span>
<div className="ml-3">
{isOpen ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
<ChevronUp className="ml-2 size-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
<ChevronDown className="ml-2 size-4 opacity-50" />
)}
</div>
</button>
@@ -252,7 +252,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
<p className="font-semibold text-slate-800">
{t("workspace.surveys.summary.show_all_responses_that_match")}
</p>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Select
value={filterValue.responseStatus ?? "all"}
onValueChange={(val) => {
@@ -1,10 +1,11 @@
import { Prisma } from "@prisma/client";
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { PrismaErrorType } from "@formbricks/database/types/error";
export const isPrismaKnownRequestError = (error: unknown): error is Prisma.PrismaClientKnownRequestError =>
export const isPrismaKnownRequestError = (error: unknown): error is PrismaClientKnownRequestError =>
error instanceof Prisma.PrismaClientKnownRequestError;
export const isSingleUseIdUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): boolean => {
export const isSingleUseIdUniqueConstraintError = (error: PrismaClientKnownRequestError): boolean => {
if (error.code !== PrismaErrorType.UniqueConstraintViolation) {
return false;
}
@@ -34,7 +34,7 @@ export const GET = async (req: Request) => {
return responses.unauthorizedResponse();
}
const basePath = `/workspaces/${workspaceId}`;
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
if (code && typeof code !== "string") {
return responses.badRequestResponse("`code` must be a string");
@@ -102,7 +102,7 @@ export const GET = async (req: Request) => {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
}
return Response.redirect(`${WEBAPP_URL}/${basePath}/integrations/google-sheets`);
return Response.redirect(`${WEBAPP_URL}${basePath}/integrations/google-sheets`);
}
return responses.internalServerErrorResponse("Failed to create or update Google Sheets integration");
+11 -2
View File
@@ -313,9 +313,18 @@ describe("handleErrorResponse", () => {
expect(body.message).toBe("bad input");
});
test("returns 400 badRequest for ResourceNotFoundError", async () => {
test("returns 404 notFound for ResourceNotFoundError", async () => {
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
expect(response.status).toBe(400);
expect(response.status).toBe(404);
const body = await response.json();
expect(body).toEqual({
code: "not_found",
message: "Survey not found",
details: {
resource_id: "id-1",
resource_type: "Survey",
},
});
});
test("returns 500 internalServerError for unknown errors", async () => {
+4 -5
View File
@@ -29,11 +29,10 @@ export const handleErrorResponse = (error: any): Response => {
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message);
}
if (
error instanceof DatabaseError ||
error instanceof InvalidInputError ||
error instanceof ResourceNotFoundError
) {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse(error.resourceType, error.resourceId);
}
if (error instanceof DatabaseError || error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
}
return responses.internalServerErrorResponse("Some error occurred");
@@ -1,6 +1,7 @@
import { logger } from "@formbricks/logger";
import { ZDisplayCreateInput } from "@formbricks/types/displays";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -32,7 +33,25 @@ export const POST = withV1ApiWrapper({
}
const { workspaceId } = resolved;
const jsonInput = await req.json();
let jsonInput;
try {
jsonInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }, true),
};
}
return {
response: responses.badRequestResponse(
"Malformed JSON input, please check your request body",
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
true
),
};
}
const inputValidation = ZDisplayCreateInput.safeParse({
...jsonInput,
workspaceId,

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