Compare commits

..

49 Commits

Author SHA1 Message Date
Theodór Tómas
4916a707d5 Merge branch 'main' into feat/css-variables 2026-02-11 00:18:36 +07:00
TheodorTomas
444632e83d fix: default button size, margin, padding 2026-02-10 19:18:58 +07:00
TheodorTomas
1fcdad6b76 fix: default preview brand colors 2026-02-10 16:29:47 +07:00
TheodorTomas
c926ce4a9d fix: onboarding preview brand color 2026-02-10 15:02:58 +07:00
Dhruwang
250daa752d Merge branch 'main' of https://github.com/formbricks/formbricks into feat/css-variables 2026-02-10 10:45:49 +05:30
TheodorTomas
30caf5d704 fixing e2e test 2026-02-09 22:21:45 +07:00
TheodorTomas
08c00daf36 Fixing issues after review 2026-02-09 21:48:11 +07:00
TheodorTomas
76be9b3470 fix: use standard secondary variant for Suggest colors button
Remove custom color/border overrides on the Suggest colors button and
rely on the built-in secondary Button variant instead.
2026-02-09 14:00:16 +07:00
TheodorTomas
c9bae02ca1 fix: use consistent fixed height for theme styling preview
Use a single 660px fixed height for the theme styling preview container
for both app and link survey types, replacing the previous conditional
height that left the link survey preview too short.
2026-02-09 13:39:38 +07:00
Johannes
9ceb490b7d Merge branch 'main' of https://github.com/formbricks/formbricks into feat/css-variables 2026-02-06 15:18:36 -03:00
TheodorTomas
b3ac2c70de fix: allow overflow in expanded StylingSection so color picker is not clipped 2026-02-06 21:03:26 +08:00
TheodorTomas
6c7aa64d2e fix: revert CSS defaults to static values and align ADVANCED_DEFAULTS with main branch
- Revert globals.css to pre-bug static defaults (no var(--fb-survey-brand-color)
  live bindings) so changing the brand color picker doesn't cascade to other
  elements
- Align ADVANCED_DEFAULTS with old main branch visual defaults (button px-3
  py-3 text-base font-medium rounded-custom, headline font-semibold, description
  slate-700, input border radius 8, etc.)
- Add missing color fields to getBrandDerivedDefaults (questionColor,
  inputColor, inputBorderColor, cardBackgroundColor, cardBorderColor) so
  first-time users with no saved styles get a fully brand-derived theme
2026-02-06 20:53:59 +08:00
TheodorTomas
0e03096014 feat: add headline to preview survey input field
The open-text input in the workspace look preview was missing a
headline label. Add "Anything else to share?" so the field is
consistent with the other question elements in the preview.
2026-02-06 18:42:27 +08:00
TheodorTomas
a422b7acc3 fix: show browser chrome header for app survey preview in theme settings
The header bar (red/amber/green dots, "Your web app" label, reset button)
was hidden for app survey previews due to a `!isAppSurvey` guard, causing
the top of the preview to appear cut off.
2026-02-06 18:26:01 +08:00
TheodorTomas
8f02655925 fix: derive styling colors from brand color and add description font weight
- Adjust suggest colors mix ratios to match original behavior (inputs at
  92% white, input borders at 60% white, question color at 35% black)
- Derive button, progress, input, option, headline, and accent colors
  from the saved brand color on initial load via getBrandDerivedDefaults
- Update CSS defaults in globals.css to reference --fb-survey-brand-color
- Add elementDescriptionFontWeight field to styling type, form, and
  addCustomThemeToDom
- Add e2e test for initial load brand color derivation
2026-02-06 17:43:57 +08:00
TheodorTomas
d31caf37ab fix: prevent StylingSection background from overflowing rounded border 2026-02-06 17:31:35 +08:00
TheodorTomas
68dee72531 feat: add brand color picker and suggest colors to survey editor styling tab
Extract shared getSuggestedColors() to keep color derivation DRY between
the project-level theme page and the per-survey styling editor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 17:26:16 +08:00
TheodorTomas
f07225c953 chore: remove unused tailwind utility extensions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 16:17:42 +08:00
TheodorTomas
495d0eb338 fix: derive suggest colors from brand color instead of hardcoding
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 16:17:39 +08:00
TheodorTomas
5c6f1e998e refactor: extract styling field components into separate files
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 16:17:35 +08:00
TheodorTomas
7563793643 Merge remote-tracking branch 'origin/main' into feat/css-variables 2026-02-06 15:05:48 +08:00
TheodorTomas
93b7dfa3fa Merge remote-tracking branch 'origin/main' into feat/css-variables 2026-02-06 11:57:53 +08:00
TheodorTomas
290a53eb8e fix: removing unneeded changes 2026-02-05 14:21:27 +08:00
TheodorTomas
afcc8069ed chore: bump node version to 20 in translation workflow 2026-02-05 03:11:25 +08:00
TheodorTomas
0b8bef5861 chore: regenerate i18n lockfiles and translations from main 2026-02-05 02:46:06 +08:00
TheodorTomas
42f47419eb fix: generating locale files again 2026-02-05 00:44:32 +08:00
TheodorTomas
62d0109837 chore: reset i18n files to match main 2026-02-05 00:33:41 +08:00
TheodorTomas
704e925d19 Merge main and resolve pnpm-lock.yaml conflict 2026-02-05 00:03:15 +08:00
TheodorTomas
5b7b3458c5 chore: update pnpm-lock.yaml to match package.json 2026-02-04 23:58:40 +08:00
TheodorTomas
cfbd67d4c4 fix: force CI to correct 2026-02-04 23:51:16 +08:00
TheodorTomas
d389037ae9 chore: removing unneeded code after self review 2026-02-04 23:39:29 +08:00
TheodorTomas
7a6ac93a7f fix: small fix 2026-02-04 23:23:34 +08:00
TheodorTomas
d7c6d465fc fix: fixing broken e2e tests 2026-02-04 23:16:22 +08:00
TheodorTomas
493aeeb1f1 fix: fixing e2e tests 2026-02-04 22:25:35 +08:00
TheodorTomas
b947b70321 fix: fixing broken HU translations after merge 2026-02-04 20:44:53 +08:00
TheodorTomas
a289af7c5d Merge remote-tracking branch 'origin/main' into feat/css-variables 2026-02-04 18:54:11 +08:00
TheodorTomas
dc9251950c feat: a couple small improvements 2026-02-04 14:15:35 +08:00
TheodorTomas
9fbe32c6ab feat: fixing the font weight not appearing in the preview 2026-02-04 13:58:46 +08:00
TheodorTomas
639d63be5e feat: removing extra comment 2026-02-04 11:45:54 +08:00
TheodorTomas
f0b3d8638b feat: renaming label-upper-label to label-upper 2026-02-04 11:38:44 +08:00
TheodorTomas
ac838e0710 feat: fixing issue from PR review 2026-02-04 11:28:28 +08:00
TheodorTomas
45fc508f5b chore: fixing rabbitai issues 2026-02-02 21:25:55 +08:00
TheodorTomas
726d4b67f9 chore: adding test coverage to styles.ts 2026-02-02 21:25:55 +08:00
TheodorTomas
2fc7827f8e chore: adding more styles.test.ts unit tests 2026-02-02 21:25:55 +08:00
TheodorTomas
a1364995d1 chore: fix unit test 2026-02-02 21:25:55 +08:00
TheodorTomas
684e0c54c7 chore: cleanup after self review 2026-02-02 21:25:55 +08:00
TheodorTomas
39851de1b9 chore: cleanup after self review 2026-02-02 21:25:55 +08:00
TheodorTomas
e5134d5824 chore: cleanup after self review 2026-02-02 21:25:51 +08:00
TheodorTomas
57555d1688 feat: advance css vars 2026-02-02 21:21:45 +08:00
1094 changed files with 25117 additions and 42725 deletions

View File

@@ -150,7 +150,6 @@ NOTION_OAUTH_CLIENT_ID=
NOTION_OAUTH_CLIENT_SECRET= NOTION_OAUTH_CLIENT_SECRET=
# Stripe Billing Variables # Stripe Billing Variables
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY= STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET= STRIPE_WEBHOOK_SECRET=
@@ -185,13 +184,8 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app # Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_DISABLED=1 # RATE_LIMITING_DISABLED=1
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics) # OpenTelemetry URL for tracing
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 # OPENTELEMETRY_LISTENER_URL=http://localhost:4318/v1/traces
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
# OTEL_SERVICE_NAME=formbricks
# OTEL_RESOURCE_ATTRIBUTES=deployment.environment=development
# OTEL_TRACES_SAMPLER=parentbased_traceidratio
# OTEL_TRACES_SAMPLER_ARG=1
# Unsplash API Key # Unsplash API Key
UNSPLASH_ACCESS_KEY= UNSPLASH_ACCESS_KEY=
@@ -231,4 +225,4 @@ REDIS_URL=redis://localhost:6379
# Lingo.dev API key for translation generation # Lingo.dev API key for translation generation
LINGODOTDEV_API_KEY=your_api_key_here LINGODOTDEV_API_KEY=your_api_key_here

View File

@@ -285,14 +285,12 @@ runs:
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }} encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
redis_url=${{ env.DUMMY_REDIS_URL }} redis_url=${{ env.DUMMY_REDIS_URL }}
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }} sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
posthog_key=${{ env.POSTHOG_KEY }}
env: env:
DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }} DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }} DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }} DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }} DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
POSTHOG_KEY: ${{ env.POSTHOG_KEY }}
- name: Sign GHCR image (GHCR only) - name: Sign GHCR image (GHCR only)
if: ${{ inputs.registry_type == 'ghcr' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }} if: ${{ inputs.registry_type == 'ghcr' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }}

View File

@@ -92,4 +92,3 @@ jobs:
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }} DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }} DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}

View File

@@ -65,8 +65,8 @@ jobs:
set -euo pipefail set -euo pipefail
echo "Updating Chart.yaml with version: ${VERSION}" echo "Updating Chart.yaml with version: ${VERSION}"
yq -i ".version = \"${VERSION}\"" charts/formbricks/Chart.yaml yq -i ".version = \"${VERSION}\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"${VERSION}\"" charts/formbricks/Chart.yaml yq -i ".appVersion = \"${VERSION}\"" helm-chart/Chart.yaml
echo "✅ Successfully updated Chart.yaml" echo "✅ Successfully updated Chart.yaml"
@@ -77,7 +77,7 @@ jobs:
set -euo pipefail set -euo pipefail
echo "Packaging Helm chart version: ${VERSION}" echo "Packaging Helm chart version: ${VERSION}"
helm package ./charts/formbricks helm package ./helm-chart
echo "✅ Successfully packaged formbricks-${VERSION}.tgz" echo "✅ Successfully packaged formbricks-${VERSION}.tgz"

View File

@@ -9,7 +9,6 @@ on:
merge_group: merge_group:
permissions: permissions:
contents: read contents: read
pull-requests: read
jobs: jobs:
sonarqube: sonarqube:
name: SonarQube name: SonarQube
@@ -51,9 +50,6 @@ jobs:
pnpm test:coverage pnpm test:coverage
- name: SonarQube Scan - name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf
with:
args: >
-Dsonar.verbose=true
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

View File

@@ -6,9 +6,19 @@ permissions:
on: on:
pull_request: pull_request:
types: [opened, synchronize, reopened] types: [opened, synchronize, reopened]
paths:
- "apps/web/**/*.ts"
- "apps/web/**/*.tsx"
- "apps/web/locales/**/*.json"
- "scan-translations.ts"
push: push:
branches: branches:
- main - main
paths:
- "apps/web/**/*.ts"
- "apps/web/**/*.tsx"
- "apps/web/locales/**/*.json"
- "scan-translations.ts"
jobs: jobs:
validate-translations: validate-translations:
@@ -23,38 +33,30 @@ jobs:
egress-policy: audit egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check for relevant changes
id: changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with: with:
filters: | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
translations:
- 'apps/web/**/*.ts'
- 'apps/web/**/*.tsx'
- 'apps/web/locales/**/*.json'
- 'packages/surveys/src/**/*.{ts,tsx}'
- 'packages/surveys/locales/**/*.json'
- 'packages/email/**/*.{ts,tsx}'
- name: Setup Node.js 22.x - name: Setup Node.js 22.x
if: steps.changes.outputs.translations == 'true'
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with: with:
node-version: 22.x node-version: 22.x
- name: Install pnpm - name: Install pnpm
if: steps.changes.outputs.translations == 'true'
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies - name: Install dependencies
if: steps.changes.outputs.translations == 'true'
run: pnpm install --config.platform=linux --config.architecture=x64 run: pnpm install --config.platform=linux --config.architecture=x64
- name: Validate translation keys - name: Validate translation keys
if: steps.changes.outputs.translations == 'true' run: |
run: pnpm run scan-translations echo ""
echo "🔍 Validating translation keys..."
echo ""
pnpm run scan-translations
- name: Skip (no translation-related changes) - name: Summary
if: steps.changes.outputs.translations != 'true' if: success()
run: echo "No translation-related files changed — skipping validation." run: |
echo ""
echo "✅ Translation validation completed successfully!"
echo ""

2
.husky/post-checkout Normal file
View File

@@ -0,0 +1,2 @@
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ./branch.json
prettier --write ./branch.json

View File

@@ -1 +1,40 @@
pnpm lint-staged # Load environment variables from .env files
if [ -f .env ]; then
set -a
. .env
set +a
fi
pnpm lint-staged
# Run Lingo.dev i18n workflow if LINGODOTDEV_API_KEY is set
if [ -n "$LINGODOTDEV_API_KEY" ]; then
echo ""
echo "🌍 Running Lingo.dev translation workflow..."
echo ""
# Run translation generation and validation
if pnpm run i18n; then
echo ""
echo "✅ Translation validation passed"
echo ""
# Add updated locale files to git
git add apps/web/locales/*.json
else
echo ""
echo "❌ Translation validation failed!"
echo ""
echo "Please fix the translation issues above before committing:"
echo " • Add missing translation keys to your locale files"
echo " • Remove unused translation keys"
echo ""
echo "Or run 'pnpm i18n' to see the detailed report"
echo ""
exit 1
fi
else
echo ""
echo "⚠️ Skipping translation validation: LINGODOTDEV_API_KEY is not set"
echo " (This is expected for community contributors)"
echo ""
fi

View File

@@ -10,20 +10,25 @@
"build-storybook": "storybook build", "build-storybook": "storybook build",
"clean": "rimraf .turbo node_modules dist storybook-static" "clean": "rimraf .turbo node_modules dist storybook-static"
}, },
"dependencies": {
"@formbricks/survey-ui": "workspace:*"
},
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "^5.0.1", "@chromatic-com/storybook": "^5.0.0",
"@storybook/addon-a11y": "10.2.17", "@storybook/addon-a11y": "10.1.11",
"@storybook/addon-links": "10.2.17", "@storybook/addon-links": "10.1.11",
"@storybook/addon-onboarding": "10.2.17", "@storybook/addon-onboarding": "10.1.11",
"@storybook/react-vite": "10.2.17", "@storybook/react-vite": "10.1.11",
"@typescript-eslint/eslint-plugin": "8.57.0", "@typescript-eslint/eslint-plugin": "8.53.0",
"@tailwindcss/vite": "4.2.1", "@tailwindcss/vite": "4.1.18",
"@typescript-eslint/parser": "8.57.0", "@typescript-eslint/parser": "8.53.0",
"@vitejs/plugin-react": "5.1.4", "@vitejs/plugin-react": "5.1.2",
"esbuild": "0.25.12",
"eslint-plugin-react-refresh": "0.4.26", "eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.2.17", "eslint-plugin-storybook": "10.1.11",
"storybook": "10.2.17", "prop-types": "15.8.1",
"storybook": "10.1.11",
"vite": "7.3.1", "vite": "7.3.1",
"@storybook/addon-docs": "10.2.17" "@storybook/addon-docs": "10.1.11"
} }
} }

View File

@@ -1,6 +0,0 @@
const baseConfig = require("../../.prettierrc.js");
module.exports = {
...baseConfig,
tailwindConfig: "./tailwind.config.js",
};

View File

@@ -18,7 +18,7 @@ FROM node:24-alpine3.23 AS base
FROM base AS installer FROM base AS installer
# Enable corepack and prepare pnpm # Enable corepack and prepare pnpm
RUN npm install --ignore-scripts -g corepack@latest RUN npm install --ignore-scripts -g corepack@latest
RUN corepack enable RUN corepack enable
RUN corepack prepare pnpm@10.28.2 --activate RUN corepack prepare pnpm@10.28.2 --activate
@@ -67,7 +67,6 @@ RUN --mount=type=secret,id=database_url \
--mount=type=secret,id=encryption_key \ --mount=type=secret,id=encryption_key \
--mount=type=secret,id=redis_url \ --mount=type=secret,id=redis_url \
--mount=type=secret,id=sentry_auth_token \ --mount=type=secret,id=sentry_auth_token \
--mount=type=secret,id=posthog_key \
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web... /tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
# #
@@ -75,10 +74,9 @@ RUN --mount=type=secret,id=database_url \
# #
FROM base AS runner FROM base AS runner
# Upgrade Alpine system packages to pick up security patches, update npm to latest, then create user # Update npm to latest, then create user
# Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime # Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime
RUN apk update && apk upgrade --no-cache \ RUN npm install --ignore-scripts -g npm@latest \
&& npm install --ignore-scripts -g npm@latest \
&& addgroup -S nextjs \ && addgroup -S nextjs \
&& adduser -S -u 1001 -G nextjs nextjs && adduser -S -u 1001 -G nextjs nextjs
@@ -103,9 +101,6 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
# Create packages/database directory structure with proper ownership for runtime migrations # Create packages/database directory structure with proper ownership for runtime migrations
RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
@@ -122,11 +117,8 @@ RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2 COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2 RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
# Runtime migrations import uuid v7 from the database package, so copy the COPY --from=installer /app/node_modules/uuid ./node_modules/uuid
# database package's resolved install instead of the repo-root hoisted version. RUN chmod -R 755 ./node_modules/uuid
COPY --from=installer /app/packages/database/node_modules/uuid ./node_modules/uuid
RUN chmod -R 755 ./node_modules/uuid \
&& node --input-type=module -e "import('uuid').then((module) => { if (typeof module.v7 !== 'function') throw new Error('uuid v7 missing in runtime image'); })"
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
RUN chmod -R 755 ./node_modules/@noble/hashes RUN chmod -R 755 ./node_modules/@noble/hashes
@@ -134,22 +126,6 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod RUN chmod -R 755 ./node_modules/zod
# Pino loads transport code in worker threads via dynamic require().
# Next.js file tracing only traces static imports, missing runtime-loaded files
# (e.g. pino/lib/transport-stream.js, transport targets).
# Copy the full packages to ensure all runtime files are available.
COPY --from=installer /app/node_modules/pino ./node_modules/pino
RUN chmod -R 755 ./node_modules/pino
COPY --from=installer /app/node_modules/pino-opentelemetry-transport ./node_modules/pino-opentelemetry-transport
RUN chmod -R 755 ./node_modules/pino-opentelemetry-transport
COPY --from=installer /app/node_modules/pino-abstract-transport ./node_modules/pino-abstract-transport
RUN chmod -R 755 ./node_modules/pino-abstract-transport
COPY --from=installer /app/node_modules/otlp-logger ./node_modules/otlp-logger
RUN chmod -R 755 ./node_modules/otlp-logger
# Install prisma CLI globally for database migrations and fix permissions for nextjs user # Install prisma CLI globally for database migrations and fix permissions for nextjs user
RUN npm install --ignore-scripts -g prisma@6 \ RUN npm install --ignore-scripts -g prisma@6 \
&& chown -R nextjs:nextjs /usr/local/lib/node_modules/prisma && chown -R nextjs:nextjs /usr/local/lib/node_modules/prisma
@@ -169,4 +145,4 @@ RUN mkdir -p /home/nextjs/apps/web/uploads/ && \
VOLUME /home/nextjs/apps/web/uploads/ VOLUME /home/nextjs/apps/web/uploads/
VOLUME /home/nextjs/apps/web/saml-connection VOLUME /home/nextjs/apps/web/saml-connection
CMD ["/home/nextjs/start.sh"] CMD ["/home/nextjs/start.sh"]

View File

@@ -69,7 +69,7 @@ export const ConnectWithFormbricks = ({
) : ( ) : (
<div className="flex animate-pulse flex-col items-center space-y-4"> <div className="flex animate-pulse flex-col items-center space-y-4">
<span className="relative flex h-10 w-10"> <span className="relative flex h-10 w-10">
<span className="absolute inline-flex h-full w-full animate-ping-slow rounded-full bg-slate-400 opacity-75"></span> <span className="animate-ping-slow absolute inline-flex h-full w-full 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 h-10 w-10 rounded-full bg-slate-500"></span>
</span> </span>
<p className="pt-4 text-sm font-medium text-slate-600"> <p className="pt-4 text-sm font-medium text-slate-600">

View File

@@ -46,7 +46,7 @@ const Page = async (props: ConnectPageProps) => {
channel={channel} channel={channel}
/> />
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={`/environments/${environment.id}`}> <Link href={`/environments/${environment.id}`}>

View File

@@ -4,10 +4,7 @@ import { AuthorizationError } from "@formbricks/types/errors";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
const OnboardingLayout = async (props: { const OnboardingLayout = async (props) => {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;

View File

@@ -2,7 +2,6 @@ import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react"; import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest"; import { afterEach, describe, expect, test } from "vitest";
import { TProject } from "@formbricks/types/project"; import { TProject } from "@formbricks/types/project";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TXMTemplate } from "@formbricks/types/templates"; import { TXMTemplate } from "@formbricks/types/templates";
import { replacePresetPlaceholders } from "./utils"; import { replacePresetPlaceholders } from "./utils";
@@ -40,13 +39,13 @@ const mockTemplate: TXMTemplate = {
elements: [ elements: [
{ {
id: "q1", id: "q1",
type: "openText" as TSurveyElementTypeEnum.OpenText, type: "openText" as const,
inputType: "text" as const, inputType: "text" as const,
headline: { default: "$[projectName] Question" }, headline: { default: "$[projectName] Question" },
subheader: { default: "" }, subheader: { default: "" },
required: false, required: false,
placeholder: { default: "" }, placeholder: { default: "" },
charLimit: { enabled: true, max: 1000 }, charLimit: 1000,
}, },
], ],
}, },

View File

@@ -14,7 +14,7 @@ describe("xm-templates", () => {
}); });
test("getXMSurveyDefault returns default survey template", () => { test("getXMSurveyDefault returns default survey template", () => {
const tMock = vi.fn((key: string) => key) as unknown as TFunction; const tMock = vi.fn((key) => key) as TFunction;
const result = getXMSurveyDefault(tMock); const result = getXMSurveyDefault(tMock);
expect(result).toEqual({ expect(result).toEqual({
@@ -29,7 +29,7 @@ describe("xm-templates", () => {
}); });
test("getXMTemplates returns all templates", () => { test("getXMTemplates returns all templates", () => {
const tMock = vi.fn((key: string) => key) as unknown as TFunction; const tMock = vi.fn((key) => key) as TFunction;
const result = getXMTemplates(tMock); const result = getXMTemplates(tMock);
expect(result).toHaveLength(6); expect(result).toHaveLength(6);
@@ -44,7 +44,7 @@ describe("xm-templates", () => {
test("getXMTemplates handles errors gracefully", async () => { test("getXMTemplates handles errors gracefully", async () => {
const tMock = vi.fn(() => { const tMock = vi.fn(() => {
throw new Error("Test error"); throw new Error("Test error");
}) as unknown as TFunction; }) as TFunction;
const result = getXMTemplates(tMock); const result = getXMTemplates(tMock);

View File

@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
<XMTemplateList project={project} user={user} environmentId={environment.id} /> <XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && ( {projects.length >= 2 && (
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={`/environments/${environment.id}/surveys`}> <Link href={`/environments/${environment.id}/surveys`}>

View File

@@ -19,8 +19,8 @@ describe("getTeamsByOrganizationId", () => {
test("returns mapped teams", async () => { test("returns mapped teams", async () => {
const mockTeams = [ const mockTeams = [
{ id: "t1", name: "Team 1", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" }, { id: "t1", name: "Team 1" },
{ id: "t2", name: "Team 2", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" }, { id: "t2", name: "Team 2" },
]; ];
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams); vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
const result = await getTeamsByOrganizationId("org1"); const result = await getTeamsByOrganizationId("org1");

View File

@@ -22,10 +22,12 @@ export const getTeamsByOrganizationId = reactCache(
}, },
}); });
return teams.map((team: TOrganizationTeam) => ({ const projectTeams = teams.map((team) => ({
id: team.id, id: team.id,
name: team.name, name: team.name,
})); }));
return projectTeams;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message); throw new DatabaseError(error.message);

View File

@@ -42,7 +42,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
return ( return (
<aside <aside
className={cn( className={cn(
"z-40 flex w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100" "w-sidebar-collapsed z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
)}> )}>
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} /> <Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />

View File

@@ -5,10 +5,7 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getUserProjects } from "@/lib/project/service"; import { getUserProjects } from "@/lib/project/service";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
const LandingLayout = async (props: { const LandingLayout = async (props) => {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;

View File

@@ -10,7 +10,7 @@ import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
const Page = async (props: { params: Promise<{ organizationId: string }> }) => { const Page = async (props) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();

View File

@@ -8,10 +8,7 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client"; import { ToasterClient } from "@/modules/ui/components/toaster-client";
const ProjectOnboardingLayout = async (props: { const ProjectOnboardingLayout = async (props) => {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;

View File

@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
<OnboardingOptionsContainer options={channelOptions} /> <OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>

View File

@@ -8,10 +8,7 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils"; import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
const OnboardingLayout = async (props: { const OnboardingLayout = async (props) => {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
@@ -31,10 +28,8 @@ const OnboardingLayout = async (props: {
throw new Error(t("common.organization_not_found")); throw new Error(t("common.organization_not_found"));
} }
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([ const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
getOrganizationProjectsLimit(organization.id), const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
getOrganizationProjectsCount(organization.id),
]);
if (organizationProjectsCount >= organizationProjectsLimit) { if (organizationProjectsCount >= organizationProjectsLimit) {
return redirect(`/`); return redirect(`/`);

View File

@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
<OnboardingOptionsContainer options={channelOptions} /> <OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>

View File

@@ -1,22 +0,0 @@
import { getTranslate } from "@/lingodotdev/server";
import { SelectPlanCard } from "@/modules/ee/billing/components/select-plan-card";
import { Header } from "@/modules/ui/components/header";
interface SelectPlanOnboardingProps {
organizationId: string;
}
export const SelectPlanOnboarding = async ({ organizationId }: SelectPlanOnboardingProps) => {
const t = await getTranslate();
const nextUrl = `/organizations/${organizationId}/workspaces/new/mode`;
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-8">
<Header
title={t("environments.settings.billing.select_plan_header_title")}
subtitle={t("environments.settings.billing.select_plan_header_subtitle")}
/>
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} />
</div>
);
};

View File

@@ -1,42 +0,0 @@
import { redirect } from "next/navigation";
import { TCloudBillingPlan } from "@formbricks/types/organizations";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getOrganizationBillingWithReadThroughSync } from "@/modules/ee/billing/lib/organization-billing";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { SelectPlanOnboarding } from "./components/select-plan-onboarding";
const PAID_PLANS = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
interface PlanPageProps {
params: Promise<{
organizationId: string;
}>;
}
const Page = async (props: PlanPageProps) => {
const params = await props.params;
if (!IS_FORMBRICKS_CLOUD) {
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
}
const { session } = await getOrganizationAuth(params.organizationId);
if (!session?.user) {
return redirect(`/auth/login`);
}
// Users with an existing paid/trial subscription should not be shown the trial page.
// Redirect them directly to the next onboarding step.
const billing = await getOrganizationBillingWithReadThroughSync(params.organizationId);
const currentPlan = billing?.stripe?.plan;
const hasExistingSubscription = currentPlan !== undefined && PAID_PLANS.has(currentPlan);
if (hasExistingSubscription) {
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
}
return <SelectPlanOnboarding organizationId={params.organizationId} />;
};
export default Page;

View File

@@ -228,7 +228,7 @@ export const ProjectSettings = ({
</FormProvider> </FormProvider>
</div> </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 h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow">
{logoUrl && ( {logoUrl && (
<Image <Image
src={logoUrl} src={logoUrl}
@@ -239,16 +239,18 @@ export const ProjectSettings = ({
/> />
)} )}
<p className="text-sm text-slate-400">{t("common.preview")}</p> <p className="text-sm text-slate-400">{t("common.preview")}</p>
<SurveyInline <div className="z-0 h-3/4 w-3/4">
appUrl={publicDomain} <SurveyInline
isPreviewMode={true} appUrl={publicDomain}
survey={previewSurvey(projectName || t("common.my_product"), t)} isPreviewMode={true}
styling={previewStyling} survey={previewSurvey(projectName || "my Product", t)}
isBrandingEnabled={false} styling={previewStyling}
languageCode="default" isBrandingEnabled={false}
onFileUpload={async (file) => file.name} languageCode="default"
autoFocus={false} onFileUpload={async (file) => file.name}
/> autoFocus={false}
/>
</div>
</div> </div>
<CreateTeamModal <CreateTeamModal
open={createTeamModalOpen} open={createTeamModalOpen}

View File

@@ -42,7 +42,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
const organizationTeams = await getTeamsByOrganizationId(params.organizationId); const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
const isAccessControlAllowed = await getAccessControlPermission(organization.id); const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
if (!organizationTeams) { if (!organizationTeams) {
throw new Error(t("common.organization_teams_not_found")); throw new Error(t("common.organization_teams_not_found"));
@@ -69,7 +69,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
/> />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>

View File

@@ -16,7 +16,7 @@ interface OnboardingOptionsContainerProps {
} }
export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => { export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => {
const getOptionCard = (option: OnboardingOptionsContainerProps["options"][number]) => { const getOptionCard = (option) => {
const Icon = option.icon; const Icon = option.icon;
return ( return (
<OptionCard <OptionCard

View File

@@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
export const ZOrganizationTeam = z.object({ export const ZOrganizationTeam = z.object({
id: z.cuid2(), id: z.string().cuid2(),
name: z.string(), name: z.string(),
}); });

View File

@@ -2,10 +2,7 @@ import { redirect } from "next/navigation";
import { getEnvironment } from "@/lib/environment/service"; import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils"; import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
const SurveyEditorEnvironmentLayout = async (props: { const SurveyEditorEnvironmentLayout = async (props) => {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;

View File

@@ -6,26 +6,15 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Confetti } from "@/modules/ui/components/confetti"; import { Confetti } from "@/modules/ui/components/confetti";
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId"; interface ConfirmationPageProps {
environmentId: string;
}
export const ConfirmationPage = () => { export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [showConfetti, setShowConfetti] = useState(false); const [showConfetti, setShowConfetti] = useState(false);
const [resolvedEnvironmentId, setResolvedEnvironmentId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
setShowConfetti(true); setShowConfetti(true);
if (globalThis.window === undefined) {
return;
}
const storedEnvironmentId = globalThis.window.sessionStorage.getItem(
BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY
);
if (storedEnvironmentId) {
setResolvedEnvironmentId(storedEnvironmentId);
}
}, []); }, []);
return ( return (
@@ -41,12 +30,7 @@ export const ConfirmationPage = () => {
</p> </p>
</div> </div>
<Button asChild className="w-full justify-center"> <Button asChild className="w-full justify-center">
<Link <Link href={`/environments/${environmentId}/settings/billing`}>
href={
resolvedEnvironmentId
? `/environments/${resolvedEnvironmentId}/settings/billing`
: "/environments"
}>
{t("billing_confirmation.back_to_billing_overview")} {t("billing_confirmation.back_to_billing_overview")}
</Link> </Link>
</Button> </Button>

View File

@@ -3,10 +3,13 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const Page = async () => { const Page = async (props) => {
const searchParams = await props.searchParams;
const { environmentId } = searchParams;
return ( return (
<PageContentWrapper> <PageContentWrapper>
<ConfirmationPage /> <ConfirmationPage environmentId={environmentId?.toString()} />
</PageContentWrapper> </PageContentWrapper>
); );
}; };

View File

@@ -2,7 +2,7 @@
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors"; import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project"; import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
@@ -10,6 +10,7 @@ import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service"; import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { import {
getAccessControlPermission, getAccessControlPermission,
@@ -24,63 +25,67 @@ const ZCreateProjectAction = z.object({
data: ZProjectUpdateInput, data: ZProjectUpdateInput,
}); });
export const createProjectAction = authenticatedActionClient.inputSchema(ZCreateProjectAction).action( export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
withAuditLogging("created", "project", async ({ ctx, parsedInput }) => { withAuditLogging(
const { user } = ctx; "created",
"project",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const { user } = ctx;
const organizationId = parsedInput.organizationId; const organizationId = parsedInput.organizationId;
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: user.id, userId: user.id,
organizationId: parsedInput.organizationId, organizationId: parsedInput.organizationId,
access: [ access: [
{ {
data: parsedInput.data, data: parsedInput.data,
schema: ZProjectUpdateInput, schema: ZProjectUpdateInput,
type: "organization", type: "organization",
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
], ],
}); });
const organization = await getOrganization(organizationId); const organization = await getOrganization(organizationId);
if (!organization) { if (!organization) {
throw new Error("Organization not found"); throw new Error("Organization not found");
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization workspace limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
if (!isAccessControlAllowed) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
} }
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization workspace limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
if (!isAccessControlAllowed) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
}
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
},
};
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
return project;
} }
)
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
},
};
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
return project;
})
); );
const ZGetOrganizationsForSwitcherAction = z.object({ const ZGetOrganizationsForSwitcherAction = z.object({
@@ -92,7 +97,7 @@ const ZGetOrganizationsForSwitcherAction = z.object({
* Called on-demand when user opens the organization switcher. * Called on-demand when user opens the organization switcher.
*/ */
export const getOrganizationsForSwitcherAction = authenticatedActionClient export const getOrganizationsForSwitcherAction = authenticatedActionClient
.inputSchema(ZGetOrganizationsForSwitcherAction) .schema(ZGetOrganizationsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -117,7 +122,7 @@ const ZGetProjectsForSwitcherAction = z.object({
* Called on-demand when user opens the project switcher. * Called on-demand when user opens the project switcher.
*/ */
export const getProjectsForSwitcherAction = authenticatedActionClient export const getProjectsForSwitcherAction = authenticatedActionClient
.inputSchema(ZGetProjectsForSwitcherAction) .schema(ZGetProjectsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -133,7 +138,7 @@ export const getProjectsForSwitcherAction = authenticatedActionClient
// Need membership for getProjectsByUserId (1 DB query) // Need membership for getProjectsByUserId (1 DB query)
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId); const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
if (!membership) { if (!membership) {
throw new AuthorizationError("Membership not found"); throw new Error("Membership not found");
} }
return await getProjectsByUserId(ctx.user.id, membership); return await getProjectsByUserId(ctx.user.id, membership);

View File

@@ -29,6 +29,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
isAccessControlAllowed, isAccessControlAllowed,
projectPermission, projectPermission,
license, license,
peopleCount,
responseCount, responseCount,
} = layoutData; } = layoutData;
@@ -37,7 +38,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
const { features, lastChecked, isPendingDowngrade, active, status } = license; const { features, lastChecked, isPendingDowngrade, active, status } = license;
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false; const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id); const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const isOwnerOrManager = isOwner || isManager; const isOwnerOrManager = isOwner || isManager;
// Validate that project permission exists for members // Validate that project permission exists for members
@@ -51,6 +52,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
<LimitsReachedBanner <LimitsReachedBanner
organization={organization} organization={organization}
environmentId={environment.id} environmentId={environment.id}
peopleCount={peopleCount}
responseCount={responseCount} responseCount={responseCount}
/> />
)} )}

View File

@@ -11,7 +11,6 @@ import {
RocketIcon, RocketIcon,
UserCircleIcon, UserCircleIcon,
UserIcon, UserIcon,
WorkflowIcon,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
@@ -28,7 +27,6 @@ import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions"; import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { ProfileAvatar } from "@/modules/ui/components/avatars"; import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -111,26 +109,16 @@ export const MainNavigation = ({
href: `/environments/${environment.id}/contacts`, href: `/environments/${environment.id}/contacts`,
name: t("common.contacts"), name: t("common.contacts"),
icon: UserIcon, icon: UserIcon,
isActive: isActive: pathname?.includes("/contacts") || pathname?.includes("/segments"),
pathname?.includes("/contacts") ||
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
},
{
name: t("common.workflows"),
href: `/environments/${environment.id}/workflows`,
icon: WorkflowIcon,
isActive: pathname?.includes("/workflows"),
isHidden: !isFormbricksCloud,
}, },
{ {
name: t("common.configuration"), name: t("common.configuration"),
href: `/environments/${environment.id}/workspace/general`, href: `/environments/${environment.id}/workspace/general`,
icon: Cog, icon: Cog,
isActive: pathname?.includes("/workspace"), isActive: pathname?.includes("/project"),
}, },
], ],
[t, environment.id, pathname, isFormbricksCloud] [t, environment.id, pathname]
); );
const dropdownNavigation = [ const dropdownNavigation = [
@@ -168,20 +156,6 @@ export const MainNavigation = ({
if (isOwnerOrManager) loadReleases(); if (isOwnerOrManager) loadReleases();
}, [isOwnerOrManager]); }, [isOwnerOrManager]);
const trialDaysRemaining = useMemo(() => {
if (!isFormbricksCloud || organization.billing?.stripe?.subscriptionStatus !== "trialing") return null;
const trialEnd = organization.billing.stripe.trialEnd;
if (!trialEnd) return null;
const ts = new Date(trialEnd).getTime();
if (!Number.isFinite(ts)) return null;
const msPerDay = 86_400_000;
return Math.ceil((ts - Date.now()) / msPerDay);
}, [
isFormbricksCloud,
organization.billing?.stripe?.subscriptionStatus,
organization.billing?.stripe?.trialEnd,
]);
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`; const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
return ( return (
@@ -211,7 +185,7 @@ export const MainNavigation = ({
size="icon" size="icon"
onClick={toggleSidebar} onClick={toggleSidebar}
className={cn( className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent" "rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
)}> )}>
{isCollapsed ? ( {isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} /> <PanelLeftOpenIcon strokeWidth={1.5} />
@@ -256,13 +230,6 @@ export const MainNavigation = ({
</Link> </Link>
)} )}
{/* Trial Days Remaining */}
{!isCollapsed && isFormbricksCloud && trialDaysRemaining !== null && (
<Link href={`/environments/${environment.id}/settings/billing`} className="m-2 block">
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
</Link>
)}
{/* User Switch */} {/* User Switch */}
<div className="flex items-center"> <div className="flex items-center">
<DropdownMenu> <DropdownMenu>

View File

@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
<currentStatus.icon /> <currentStatus.icon />
</div> </div>
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p> <p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p> <p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
{status === "notImplemented" && ( {status === "notImplemented" && (
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}> <Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
<RotateCcwIcon /> <RotateCcwIcon />

View File

@@ -81,7 +81,7 @@ export const OrganizationBreadcrumb = ({
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => { getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) { if (result?.data) {
// Sort organizations by name // Sort organizations by name
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name)); const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
setOrganizations(sorted); setOrganizations(sorted);
} else { } else {
// Handle server errors or validation errors // Handle server errors or validation errors

View File

@@ -82,7 +82,7 @@ export const ProjectBreadcrumb = ({
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => { getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) { if (result?.data) {
// Sort projects by name // Sort projects by name
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name)); const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
setProjects(sorted); setProjects(sorted);
} else { } else {
// Handle server errors or validation errors // Handle server errors or validation errors

View File

@@ -4,7 +4,7 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
const EnvironmentPage = async (props: { params: Promise<{ environmentId: string }> }) => { const EnvironmentPage = async (props) => {
const params = await props.params; const params = await props.params;
const { session, organization } = await getEnvironmentAuth(params.environmentId); const { session, organization } = await getEnvironmentAuth(params.environmentId);

View File

@@ -4,10 +4,7 @@ import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
const AccountSettingsLayout = async (props: { const AccountSettingsLayout = async (props) => {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;

View File

@@ -4,6 +4,7 @@ import { z } from "zod";
import { ZUserNotificationSettings } from "@formbricks/types/user"; import { ZUserNotificationSettings } from "@formbricks/types/user";
import { getUser, updateUser } from "@/lib/user/service"; import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
const ZUpdateNotificationSettingsAction = z.object({ const ZUpdateNotificationSettingsAction = z.object({
@@ -11,16 +12,26 @@ const ZUpdateNotificationSettingsAction = z.object({
}); });
export const updateNotificationSettingsAction = authenticatedActionClient export const updateNotificationSettingsAction = authenticatedActionClient
.inputSchema(ZUpdateNotificationSettingsAction) .schema(ZUpdateNotificationSettingsAction)
.action( .action(
withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => { withAuditLogging(
const oldObject = await getUser(ctx.user.id); "updated",
const result = await updateUser(ctx.user.id, { "user",
notificationSettings: parsedInput.notificationSettings, async ({
}); ctx,
ctx.auditLoggingCtx.userId = ctx.user.id; parsedInput,
ctx.auditLoggingCtx.oldObject = oldObject; }: {
ctx.auditLoggingCtx.newObject = result; ctx: AuthenticatedActionClientCtx;
return result; parsedInput: Record<string, any>;
}) }) => {
const oldObject = await getUser(ctx.user.id);
const result = await updateUser(ctx.user.id, {
notificationSettings: parsedInput.notificationSettings,
});
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
); );

View File

@@ -30,7 +30,7 @@ export const NotificationSwitch = ({
const isChecked = const isChecked =
notificationType === "unsubscribedOrganizationIds" notificationType === "unsubscribedOrganizationIds"
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId) ? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
: notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true; : notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true;
const handleSwitchChange = async () => { const handleSwitchChange = async () => {
setIsLoading(true); setIsLoading(true);
@@ -49,11 +49,8 @@ export const NotificationSwitch = ({
]; ];
} }
} else { } else {
updatedNotificationSettings[notificationType] = { updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId] =
...updatedNotificationSettings[notificationType], !updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
[surveyOrProjectOrOrganizationId]:
!updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId],
};
} }
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({ const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
@@ -81,7 +78,7 @@ export const NotificationSwitch = ({
) { ) {
switch (notificationType) { switch (notificationType) {
case "alert": case "alert":
if (notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true) { if (notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true) {
handleSwitchChange(); handleSwitchChange();
toast.success( toast.success(
t( t(

View File

@@ -16,8 +16,8 @@ const setCompleteNotificationSettings = (
notificationSettings: TUserNotificationSettings, notificationSettings: TUserNotificationSettings,
memberships: Membership[] memberships: Membership[]
): TUserNotificationSettings => { ): TUserNotificationSettings => {
const newNotificationSettings: TUserNotificationSettings = { const newNotificationSettings = {
alert: {} as Record<string, boolean>, alert: {},
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [], unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
}; };
for (const membership of memberships) { for (const membership of memberships) {
@@ -26,8 +26,7 @@ const setCompleteNotificationSettings = (
for (const environment of project.environments) { for (const environment of project.environments) {
for (const survey of environment.surveys) { for (const survey of environment.surveys) {
newNotificationSettings.alert[survey.id] = newNotificationSettings.alert[survey.id] =
(notificationSettings as unknown as Record<string, Record<string, boolean>>)[survey.id] notificationSettings[survey.id]?.responseFinished ||
?.responseFinished ||
(notificationSettings.alert && notificationSettings.alert[survey.id]) || (notificationSettings.alert && notificationSettings.alert[survey.id]) ||
false; // check for legacy notification settings w/o "alerts" key false; // check for legacy notification settings w/o "alerts" key
} }
@@ -137,10 +136,7 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
return memberships; return memberships;
}; };
const Page = async (props: { const Page = async (props) => {
params: Promise<{ environmentId: string }>;
searchParams: Promise<Record<string, string>>;
}) => {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();

View File

@@ -20,7 +20,7 @@ import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email"; import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput { function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
return { return {
...(parsedInput.name && { name: parsedInput.name }), ...(parsedInput.name && { name: parsedInput.name }),
...(parsedInput.locale && { locale: parsedInput.locale }), ...(parsedInput.locale && { locale: parsedInput.locale }),
@@ -63,36 +63,50 @@ async function handleEmailUpdate({
return payload; return payload;
} }
export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPersonalInfoUpdateInput).action( export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => { withAuditLogging(
const oldObject = await getUser(ctx.user.id); "updated",
let payload = buildUserUpdatePayload(parsedInput); "user",
payload = await handleEmailUpdate({ ctx, parsedInput, payload }); async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: TUserPersonalInfoUpdateInput;
}) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
// Only proceed with updateUser if we have actual changes to make // Only proceed with updateUser if we have actual changes to make
let newObject = oldObject; let newObject = oldObject;
if (Object.keys(payload).length > 0) { if (Object.keys(payload).length > 0) {
newObject = await updateUser(ctx.user.id, payload); newObject = await updateUser(ctx.user.id, payload);
}
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
} }
)
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
})
); );
export const resetPasswordAction = authenticatedActionClient.action( export const resetPasswordAction = authenticatedActionClient.action(
withAuditLogging("passwordReset", "user", async ({ ctx }) => { withAuditLogging(
if (ctx.user.identityProvider !== "email") { "passwordReset",
throw new OperationNotAllowedError("Password reset is not allowed for this user."); "user",
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
}
await sendForgotPasswordEmail(ctx.user);
ctx.auditLoggingCtx.userId = ctx.user.id;
return { success: true };
} }
)
await sendForgotPasswordEmail(ctx.user);
ctx.auditLoggingCtx.userId = ctx.user.id;
return { success: true };
})
); );

View File

@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { z } from "zod"; import { z } from "zod";
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user"; import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal"; import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
import { appLanguages, sortedAppLanguages } from "@/lib/i18n/utils"; import { appLanguages } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -198,54 +198,41 @@ export const EditProfileDetailsForm = ({
<FormField <FormField
control={form.control} control={form.control}
name="locale" name="locale"
render={({ field }) => { render={({ field }) => (
const selectedLanguage = appLanguages.find((l) => l.code === field.value); <FormItem className="mt-4">
<FormLabel>{t("common.language")}</FormLabel>
return ( <FormControl>
<FormItem className="mt-4"> <DropdownMenu>
<FormLabel>{t("common.language")}</FormLabel> <DropdownMenuTrigger asChild>
<FormControl> <Button
<DropdownMenu> type="button"
<DropdownMenuTrigger asChild> variant="ghost"
<Button className="h-10 w-full border border-slate-300 px-3 text-left">
type="button" <div className="flex w-full items-center justify-between">
variant="ghost" {appLanguages.find((l) => l.code === field.value)?.label["en-US"] ?? "NA"}
className="h-10 w-full border border-slate-300 px-3 text-left"> <ChevronDownIcon className="h-4 w-4 text-slate-500" />
<div className="flex w-full items-center justify-between"> </div>
{selectedLanguage ? ( </Button>
<> </DropdownMenuTrigger>
{selectedLanguage.label["en-US"]} <DropdownMenuContent
{selectedLanguage.label.native !== selectedLanguage.label["en-US"] && className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
` (${selectedLanguage.label.native})`} align="start">
</> <DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
) : ( {appLanguages.map((lang) => (
t("common.select") <DropdownMenuRadioItem
)} key={lang.code}
<ChevronDownIcon className="h-4 w-4 text-slate-500" /> value={lang.code}
</div> className="min-h-8 cursor-pointer">
</Button> {lang.label["en-US"]}
</DropdownMenuTrigger> </DropdownMenuRadioItem>
<DropdownMenuContent ))}
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700" </DropdownMenuRadioGroup>
align="start"> </DropdownMenuContent>
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}> </DropdownMenu>
{sortedAppLanguages.map((lang) => ( </FormControl>
<DropdownMenuRadioItem <FormError />
key={lang.code} </FormItem>
value={lang.code} )}
className="min-h-8 cursor-pointer">
{lang.label["en-US"]}
{lang.label.native !== lang.label["en-US"] && ` (${lang.label.native})`}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
<FormError />
</FormItem>
);
}}
/> />
{isPasswordResetEnabled && ( {isPasswordResetEnabled && (

View File

@@ -98,7 +98,7 @@ export const PasswordConfirmationModal = ({
aria-label="password" aria-label="password"
aria-required="true" aria-required="true"
required required
className="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm" className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
value={field.value} value={field.value}
onChange={(password) => field.onChange(password)} onChange={(password) => field.onChange(password)}
/> />

View File

@@ -60,7 +60,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
buttons={[ buttons={[
{ {
text: IS_FORMBRICKS_CLOUD text: IS_FORMBRICKS_CLOUD
? t("common.upgrade_plan") ? t("common.start_free_trial")
: t("common.request_trial_license"), : t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD href: IS_FORMBRICKS_CLOUD
? `/environments/${params.environmentId}/settings/billing` ? `/environments/${params.environmentId}/settings/billing`

View File

@@ -28,7 +28,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
throw new Error(t("common.session_not_found")); throw new Error(t("common.session_not_found"));
} }
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id); const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
const isOwnerOrManager = isManager || isOwner; const isOwnerOrManager = isManager || isOwner;
const surveys = await getSurveysWithSlugsByOrganizationId(organization.id); const surveys = await getSurveysWithSlugsByOrganizationId(organization.id);

View File

@@ -1,155 +0,0 @@
"use client";
import { TFunction } from "i18next";
import { RotateCcwIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { SettingsCard } from "../../../components/SettingsCard";
interface EnterpriseLicenseStatusProps {
status: TLicenseStatus;
gracePeriodEnd?: Date;
environmentId: string;
}
const getBadgeConfig = (
status: TLicenseStatus,
t: TFunction
): { type: "success" | "error" | "warning" | "gray"; label: string } => {
switch (status) {
case "active":
return { type: "success", label: t("environments.settings.enterprise.license_status_active") };
case "expired":
return { type: "error", label: t("environments.settings.enterprise.license_status_expired") };
case "instance_mismatch":
return {
type: "error",
label: t("environments.settings.enterprise.license_status_instance_mismatch"),
};
case "unreachable":
return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") };
case "invalid_license":
return { type: "error", label: t("environments.settings.enterprise.license_status_invalid") };
default:
return { type: "gray", label: t("environments.settings.enterprise.license_status") };
}
};
export const EnterpriseLicenseStatus = ({
status,
gracePeriodEnd,
environmentId,
}: EnterpriseLicenseStatusProps) => {
const { t } = useTranslation();
const router = useRouter();
const [isRechecking, setIsRechecking] = useState(false);
const handleRecheck = async () => {
setIsRechecking(true);
try {
const result = await recheckLicenseAction({ environmentId });
if (result?.serverError) {
toast.error(result.serverError || t("environments.settings.enterprise.recheck_license_failed"));
return;
}
if (result?.data) {
if (result.data.status === "unreachable") {
toast.error(t("environments.settings.enterprise.recheck_license_unreachable"));
} else if (result.data.status === "instance_mismatch") {
toast.error(t("environments.settings.enterprise.recheck_license_instance_mismatch"));
} else if (result.data.status === "invalid_license") {
toast.error(t("environments.settings.enterprise.recheck_license_invalid"));
} else {
toast.success(t("environments.settings.enterprise.recheck_license_success"));
}
router.refresh();
} else {
toast.error(t("environments.settings.enterprise.recheck_license_failed"));
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("environments.settings.enterprise.recheck_license_failed")
);
} finally {
setIsRechecking(false);
}
};
const badgeConfig = getBadgeConfig(status, t);
return (
<SettingsCard
title={t("environments.settings.enterprise.license_status")}
description={t("environments.settings.enterprise.license_status_description")}>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-1.5">
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRecheck}
disabled={isRechecking}
className="shrink-0">
{isRechecking ? (
<>
<RotateCcwIcon className="mr-2 h-4 w-4 animate-spin" />
{t("environments.settings.enterprise.rechecking")}
</>
) : (
<>
<RotateCcwIcon className="mr-2 h-4 w-4" />
{t("environments.settings.enterprise.recheck_license")}
</>
)}
</Button>
</div>
{status === "unreachable" && gracePeriodEnd && (
<Alert variant="warning" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_unreachable_grace_period", {
gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
}),
})}
</AlertDescription>
</Alert>
)}
{status === "invalid_license" && (
<Alert variant="error" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_invalid_description")}
</AlertDescription>
</Alert>
)}
{status === "instance_mismatch" && (
<Alert variant="error" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_instance_mismatch_description")}
</AlertDescription>
</Alert>
)}
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
<a
className="font-medium text-slate-700 underline hover:text-slate-900"
href="mailto:hola@formbricks.com">
hola@formbricks.com
</a>
</p>
</div>
</SettingsCard>
);
};

View File

@@ -2,16 +2,15 @@ import { CheckIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { EnterpriseLicenseStatus } from "@/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/components/EnterpriseLicenseStatus";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license"; import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => { const Page = async (props) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
if (IS_FORMBRICKS_CLOUD) { if (IS_FORMBRICKS_CLOUD) {
@@ -26,8 +25,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
return notFound(); return notFound();
} }
const licenseState = await getEnterpriseLicense(); const { active: isEnterpriseEdition } = await getEnterpriseLicense();
const hasLicense = licenseState.status !== "no-license";
const paidFeatures = [ const paidFeatures = [
{ {
@@ -92,22 +90,35 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
activeId="enterprise" activeId="enterprise"
/> />
</PageHeader> </PageHeader>
{hasLicense ? ( {isEnterpriseEdition ? (
<EnterpriseLicenseStatus <div>
status={licenseState.status} <div className="mt-8 max-w-4xl rounded-lg border border-slate-300 bg-slate-100 shadow-sm">
gracePeriodEnd={ <div className="space-y-4 p-8">
licenseState.status === "unreachable" <div className="flex items-center gap-x-2">
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS) <div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
: undefined <CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" />
} </div>
environmentId={params.environmentId} <p className="text-slate-800">
/> {t(
"environments.settings.enterprise.your_enterprise_license_is_active_all_features_unlocked"
)}
</p>
</div>
<p className="text-sm text-slate-500">
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
<a className="font-semibold underline" href="mailto:hola@formbricks.com">
hola@formbricks.com
</a>
</p>
</div>
</div>
</div>
) : ( ) : (
<div> <div>
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0"> <div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
<svg <svg
viewBox="0 0 1024 1024" viewBox="0 0 1024 1024"
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0" className="absolute top-1/2 left-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
aria-hidden="true"> aria-hidden="true">
<circle <circle
cx={512} cx={512}
@@ -142,8 +153,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
{t("environments.settings.enterprise.enterprise_features")} {t("environments.settings.enterprise.enterprise_features")}
</h2> </h2>
<ul className="my-4 space-y-4"> <ul className="my-4 space-y-4">
{paidFeatures.map((feature) => ( {paidFeatures.map((feature, index) => (
<li key={feature.title} className="flex items-center"> <li key={index} className="flex items-center">
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800"> <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="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" />
</div> </div>

View File

@@ -7,6 +7,7 @@ import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service"; import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -16,38 +17,49 @@ const ZUpdateOrganizationNameAction = z.object({
}); });
export const updateOrganizationNameAction = authenticatedActionClient export const updateOrganizationNameAction = authenticatedActionClient
.inputSchema(ZUpdateOrganizationNameAction) .schema(ZUpdateOrganizationNameAction)
.action( .action(
withAuditLogging("updated", "organization", async ({ ctx, parsedInput }) => { withAuditLogging(
await checkAuthorizationUpdated({ "updated",
userId: ctx.user.id, "organization",
organizationId: parsedInput.organizationId, async ({
access: [ ctx,
{ parsedInput,
type: "organization", }: {
schema: ZOrganizationUpdateInput.pick({ name: true }), ctx: AuthenticatedActionClientCtx;
data: parsedInput.data, parsedInput: Record<string, any>;
roles: ["owner"], }) => {
}, await checkAuthorizationUpdated({
], userId: ctx.user.id,
}); organizationId: parsedInput.organizationId,
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId; access: [
const oldObject = await getOrganization(parsedInput.organizationId); {
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data); type: "organization",
ctx.auditLoggingCtx.oldObject = oldObject; schema: ZOrganizationUpdateInput.pick({ name: true }),
ctx.auditLoggingCtx.newObject = result; data: parsedInput.data,
return result; roles: ["owner"],
}) },
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
); );
const ZDeleteOrganizationAction = z.object({ const ZDeleteOrganizationAction = z.object({
organizationId: ZId, organizationId: ZId,
}); });
export const deleteOrganizationAction = authenticatedActionClient export const deleteOrganizationAction = authenticatedActionClient.schema(ZDeleteOrganizationAction).action(
.inputSchema(ZDeleteOrganizationAction) withAuditLogging(
.action( "deleted",
withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => { "organization",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled(); const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled"); if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
@@ -65,5 +77,6 @@ export const deleteOrganizationAction = authenticatedActionClient
const oldObject = await getOrganization(parsedInput.organizationId); const oldObject = await getOrganization(parsedInput.organizationId);
ctx.auditLoggingCtx.oldObject = oldObject; ctx.auditLoggingCtx.oldObject = oldObject;
return await deleteOrganization(parsedInput.organizationId); return await deleteOrganization(parsedInput.organizationId);
}) }
); )
);

View File

@@ -107,7 +107,7 @@ const DeleteOrganizationModal = ({
}: DeleteOrganizationModalProps) => { }: DeleteOrganizationModalProps) => {
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const { t } = useTranslation(); const { t } = useTranslation();
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e) => {
setInputValue(e.target.value); setInputValue(e.target.value);
}; };

View File

@@ -61,7 +61,7 @@ export const EditOrganizationNameForm = ({ organization, membershipRole }: EditO
toast.error(errorMessage); toast.error(errorMessage);
} }
} catch (err) { } catch (err) {
toast.error(`Error: ${err instanceof Error ? err.message : "Unknown error occurred"}`); toast.error(`Error: ${err.message}`);
} }
}; };

View File

@@ -9,7 +9,6 @@ import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { IdBadge } from "@/modules/ui/components/id-badge"; import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import packageJson from "@/package.json";
import { SettingsCard } from "../../components/SettingsCard"; import { SettingsCard } from "../../components/SettingsCard";
import { DeleteOrganization } from "./components/DeleteOrganization"; import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm"; import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
@@ -26,7 +25,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const user = session?.user?.id ? await getUser(session.user.id) : null; const user = session?.user?.id ? await getUser(session.user.id) : null;
const isMultiOrgEnabled = await getIsMultiOrgEnabled(); const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id); const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled; const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
const currentUserRole = currentUserMembership?.role; const currentUserRole = currentUserMembership?.role;
@@ -82,10 +81,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</SettingsCard> </SettingsCard>
)} )}
<div className="space-y-2"> <IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
<IdBadge id={packageJson.version} label={t("common.formbricks_version")} variant="column" />
</div>
</PageContentWrapper> </PageContentWrapper>
); );
}; };

View File

@@ -4,7 +4,7 @@ import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
const Layout = async (props: { params: Promise<{ environmentId: string }>; children: React.ReactNode }) => { const Layout = async (props) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;

View File

@@ -1,3 +1,3 @@
export const SettingsTitle = ({ title }: { title: string }) => { export const SettingsTitle = ({ title }) => {
return <h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">{title}</h2>; return <h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">{title}</h2>;
}; };

View File

@@ -1,6 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => { const Page = async (props) => {
const params = await props.params; const params = await props.params;
return redirect(`/environments/${params.environmentId}/settings/profile`); return redirect(`/environments/${params.environmentId}/settings/profile`);
}; };

View File

@@ -4,7 +4,6 @@ import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { ZResponseFilterCriteria } from "@formbricks/types/responses"; import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { getDisplaysBySurveyIdWithContact } from "@/lib/display/service";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service"; import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -23,7 +22,7 @@ const ZGetResponsesAction = z.object({
}); });
export const getResponsesAction = authenticatedActionClient export const getResponsesAction = authenticatedActionClient
.inputSchema(ZGetResponsesAction) .schema(ZGetResponsesAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -57,7 +56,7 @@ const ZGetSurveySummaryAction = z.object({
}); });
export const getSurveySummaryAction = authenticatedActionClient export const getSurveySummaryAction = authenticatedActionClient
.inputSchema(ZGetSurveySummaryAction) .schema(ZGetSurveySummaryAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -85,7 +84,7 @@ const ZGetResponseCountAction = z.object({
}); });
export const getResponseCountAction = authenticatedActionClient export const getResponseCountAction = authenticatedActionClient
.inputSchema(ZGetResponseCountAction) .schema(ZGetResponseCountAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -107,31 +106,3 @@ export const getResponseCountAction = authenticatedActionClient
return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria); return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria);
}); });
const ZGetDisplaysWithContactAction = z.object({
surveyId: ZId,
limit: z.int().min(1).max(100),
offset: z.int().nonnegative(),
});
export const getDisplaysWithContactAction = authenticatedActionClient
.inputSchema(ZGetDisplaysWithContactAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
return getDisplaysBySurveyIdWithContact(parsedInput.surveyId, parsedInput.limit, parsedInput.offset);
});

View File

@@ -3,7 +3,6 @@ import { getServerSession } from "next-auth";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context"; import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { getResponseCountBySurveyId } from "@/lib/response/service"; import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
type Props = { type Props = {
@@ -15,11 +14,10 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
const survey = await getSurvey(params.surveyId); const survey = await getSurvey(params.surveyId);
const responseCount = await getResponseCountBySurveyId(params.surveyId); const responseCount = await getResponseCountBySurveyId(params.surveyId);
const t = await getTranslate();
if (session) { if (session) {
return { return {
title: `${t("common.count_responses", { count: responseCount })} | ${t("environments.surveys.summary.survey_results", { surveyName: survey?.name })}`, title: `${responseCount} Responses | ${survey?.name} Results`,
}; };
} }
return { return {
@@ -27,7 +25,7 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
}; };
}; };
const SurveyLayout = async ({ children }: { children: React.ReactNode }) => { const SurveyLayout = async ({ children }) => {
return <ResponseFilterProvider>{children}</ResponseFilterProvider>; return <ResponseFilterProvider>{children}</ResponseFilterProvider>;
}; };

View File

@@ -205,11 +205,11 @@ export const ResponseTable = ({
}; };
// Handle downloading selected responses // Handle downloading selected responses
const downloadSelectedRows = async (responseIds: string[], format: "xlsx" | "csv") => { const downloadSelectedRows = async (responseIds: string[], format: "csv" | "xlsx") => {
try { try {
const downloadResponse = await getResponsesDownloadUrlAction({ const downloadResponse = await getResponsesDownloadUrlAction({
surveyId: survey.id, surveyId: survey.id,
format, format: format,
filterCriteria: { responseIds }, filterCriteria: { responseIds },
}); });

View File

@@ -5,7 +5,7 @@ import { TFunction } from "i18next";
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react"; import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { TResponseTableData } from "@formbricks/types/responses"; import { TResponseTableData } from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation"; import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
@@ -41,7 +41,7 @@ const getElementColumnsData = (
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"]; const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
// Helper function to create consistent column headers // Helper function to create consistent column headers
const createElementHeader = (elementType: TSurveyElementTypeEnum, headline: string, suffix?: string) => { const createElementHeader = (elementType: string, headline: string, suffix?: string) => {
const title = suffix ? `${headline} - ${suffix}` : headline; const title = suffix ? `${headline} - ${suffix}` : headline;
const ElementHeader = () => ( const ElementHeader = () => (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -232,7 +232,7 @@ const getMetadataColumnsData = (t: TFunction): ColumnDef<TResponseTableData>[] =
const metadataColumns: ColumnDef<TResponseTableData>[] = []; const metadataColumns: ColumnDef<TResponseTableData>[] = [];
METADATA_FIELDS.forEach((label) => { METADATA_FIELDS.forEach((label) => {
const IconComponent = COLUMNS_ICON_MAP[label as keyof typeof COLUMNS_ICON_MAP]; const IconComponent = COLUMNS_ICON_MAP[label];
metadataColumns.push({ metadataColumns.push({
accessorKey: "METADATA_" + label, accessorKey: "METADATA_" + label,

View File

@@ -1,5 +1,4 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { TFunction } from "i18next";
import { import {
AirplayIcon, AirplayIcon,
ArrowUpFromDotIcon, ArrowUpFromDotIcon,
@@ -39,7 +38,7 @@ describe("utils", () => {
"environments.surveys.responses.source": "Source", "environments.surveys.responses.source": "Source",
}; };
return translations[key] || key; return translations[key] || key;
}) as unknown as TFunction; });
describe("getAddressFieldLabel", () => { describe("getAddressFieldLabel", () => {
test("returns correct label for addressLine1", () => { test("returns correct label for addressLine1", () => {

View File

@@ -80,24 +80,9 @@ export const COLUMNS_ICON_MAP = {
const userAgentFields = ["browser", "os", "device"]; const userAgentFields = ["browser", "os", "device"];
export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"]; export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"];
export const getMetadataValue = ( export const getMetadataValue = (meta: TResponseMeta, label: string) => {
meta: TResponseMeta, if (userAgentFields.includes(label)) {
label: (typeof METADATA_FIELDS)[number] return meta.userAgent?.[label];
): string | undefined => {
switch (label) {
case "browser":
return meta.userAgent?.browser;
case "os":
return meta.userAgent?.os;
case "device":
return meta.userAgent?.device;
case "action":
return meta.action;
case "country":
return meta.country;
case "source":
return meta.source;
case "url":
return meta.url;
} }
return meta[label];
}; };

View File

@@ -17,7 +17,7 @@ import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
const Page = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => { const Page = async (props) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
@@ -27,7 +27,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
getSurvey(params.surveyId), getSurvey(params.surveyId),
getUser(session.user.id), getUser(session.user.id),
getTagsByEnvironmentId(params.environmentId), getTagsByEnvironmentId(params.environmentId),
getIsContactsEnabled(organization.id), getIsContactsEnabled(),
getResponseCountBySurveyId(params.surveyId), getResponseCountBySurveyId(params.surveyId),
findMatchingLocale(), findMatchingLocale(),
]); ]);
@@ -53,7 +53,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
throw new Error(t("common.organization_not_found")); throw new Error(t("common.organization_not_found"));
} }
const isQuotasAllowed = await getIsQuotasEnabled(organization.id); const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : []; const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
// Fetch initial responses on the server to prevent duplicate client-side fetch // Fetch initial responses on the server to prevent duplicate client-side fetch

View File

@@ -7,6 +7,7 @@ import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/s
import { getSurvey, updateSurvey } from "@/lib/survey/service"; import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { convertToCsv } from "@/lib/utils/file-conversion"; import { convertToCsv } from "@/lib/utils/file-conversion";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper"; import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
@@ -21,7 +22,7 @@ const ZSendEmbedSurveyPreviewEmailAction = z.object({
}); });
export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
.inputSchema(ZSendEmbedSurveyPreviewEmailAction) .schema(ZSendEmbedSurveyPreviewEmailAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const organizationLogoUrl = await getOrganizationLogoUrl(organizationId); const organizationLogoUrl = await getOrganizationLogoUrl(organizationId);
@@ -68,43 +69,53 @@ const ZResetSurveyAction = z.object({
projectId: ZId, projectId: ZId,
}); });
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action( export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => { withAuditLogging(
await checkAuthorizationUpdated({ "updated",
userId: ctx.user.id, "survey",
organizationId: parsedInput.organizationId, async ({
access: [ ctx,
{ parsedInput,
type: "organization", }: {
roles: ["owner", "manager"], ctx: AuthenticatedActionClientCtx;
}, parsedInput: z.infer<typeof ZResetSurveyAction>;
{ }) => {
type: "projectTeam", await checkAuthorizationUpdated({
minPermission: "readWrite", userId: ctx.user.id,
projectId: parsedInput.projectId, organizationId: parsedInput.organizationId,
}, access: [
], {
}); type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: parsedInput.projectId,
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId; ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId; ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null; ctx.auditLoggingCtx.oldObject = null;
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey( const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
parsedInput.surveyId parsedInput.surveyId
); );
ctx.auditLoggingCtx.newObject = { ctx.auditLoggingCtx.newObject = {
deletedResponsesCount: deletedResponsesCount, deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount, deletedDisplaysCount: deletedDisplaysCount,
}; };
return { return {
success: true, success: true,
deletedResponsesCount: deletedResponsesCount, deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount, deletedDisplaysCount: deletedDisplaysCount,
}; };
}) }
)
); );
const ZGetEmailHtmlAction = z.object({ const ZGetEmailHtmlAction = z.object({
@@ -112,7 +123,7 @@ const ZGetEmailHtmlAction = z.object({
}); });
export const getEmailHtmlAction = authenticatedActionClient export const getEmailHtmlAction = authenticatedActionClient
.inputSchema(ZGetEmailHtmlAction) .schema(ZGetEmailHtmlAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -141,10 +152,9 @@ const ZGeneratePersonalLinksAction = z.object({
}); });
export const generatePersonalLinksAction = authenticatedActionClient export const generatePersonalLinksAction = authenticatedActionClient
.inputSchema(ZGeneratePersonalLinksAction) .schema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); const isContactsEnabled = await getIsContactsEnabled();
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) { if (!isContactsEnabled) {
throw new OperationNotAllowedError("Contacts are not enabled for this environment"); throw new OperationNotAllowedError("Contacts are not enabled for this environment");
} }
@@ -221,7 +231,7 @@ const ZUpdateSingleUseLinksAction = z.object({
}); });
export const updateSingleUseLinksAction = authenticatedActionClient export const updateSingleUseLinksAction = authenticatedActionClient
.inputSchema(ZUpdateSingleUseLinksAction) .schema(ZUpdateSingleUseLinksAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,

View File

@@ -30,7 +30,8 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary.booked.count })} {elementSummary.booked.count}{" "}
{elementSummary.booked.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} /> <ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} />
@@ -46,7 +47,8 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary.skipped.count })} {elementSummary.skipped.count}{" "}
{elementSummary.skipped.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} /> <ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} />

View File

@@ -64,7 +64,7 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: summaryItem.count })} {summaryItem.count} {summaryItem.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
<div className="group-hover:opacity-80"> <div className="group-hover:opacity-80">

View File

@@ -48,7 +48,7 @@ export const ElementSummaryHeader = ({
{showResponses && ( {showResponses && (
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_responses", { count: elementSummary.responseCount })} {`${elementSummary.responseCount} ${t("common.responses")}`}
</div> </div>
)} )}
{additionalInfo} {additionalInfo}

View File

@@ -41,7 +41,8 @@ export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: Hid
</div> </div>
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_responses", { count: elementSummary.responseCount })} {elementSummary.responseCount}{" "}
{elementSummary.responseCount === 1 ? t("common.response") : t("common.responses")}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -31,7 +31,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
if (label) { if (label) {
return label; return label;
} else if (percentage !== undefined && totalResponsesForRow !== undefined) { } else if (percentage !== undefined && totalResponsesForRow !== undefined) {
return t("common.count_responses", { count: Math.round((percentage / 100) * totalResponsesForRow) }); return `${Math.round((percentage / 100) * totalResponsesForRow)} ${t("common.responses")}`;
} }
return ""; return "";
}; };
@@ -77,7 +77,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
)}> )}>
<button <button
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }} 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" className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
onClick={() => onClick={() =>
setFilter( setFilter(
elementSummary.element.id, elementSummary.element.id,

View File

@@ -75,7 +75,7 @@ export const MultipleChoiceSummary = ({
elementSummary.type === "multipleChoiceMulti" ? ( elementSummary.type === "multipleChoiceMulti" ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_selections", { count: elementSummary.selectionCount })} {`${elementSummary.selectionCount} ${t("common.selections")}`}
</div> </div>
) : undefined ) : undefined
} }
@@ -110,7 +110,7 @@ export const MultipleChoiceSummary = ({
</div> </div>
<div className="flex w-full space-x-2"> <div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0"> <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 })} {result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p> </p>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700"> <p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}% {convertFloatToNDecimal(result.percentage, 2)}%

View File

@@ -60,9 +60,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
}, },
}; };
const filter = (filters as Record<string, { comparison: string; values: string | string[] | undefined }>)[ const filter = filters[group];
group
];
if (filter) { if (filter) {
setFilter( setFilter(
@@ -106,7 +104,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
<TabsContent value="aggregated" className="mt-4"> <TabsContent value="aggregated" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6"> <div className="px-4 pb-6 pt-4 md:px-6">
<div className="space-y-5 text-sm md:text-base"> <div className="space-y-5 text-sm md:text-base">
{(["promoters", "passives", "detractors", "dismissed"] as const).map((group) => ( {["promoters", "passives", "detractors", "dismissed"].map((group) => (
<button <button
className="w-full cursor-pointer hover:opacity-80" className="w-full cursor-pointer hover:opacity-80"
key={group} key={group}
@@ -125,7 +123,8 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary[group]?.count })} {elementSummary[group]?.count}{" "}
{elementSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
<ProgressBar <ProgressBar
@@ -159,7 +158,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
}> }>
<div className="flex h-32 w-full flex-col items-center justify-end"> <div className="flex h-32 w-full flex-col items-center justify-end">
<div <div
className="w-full rounded-t-lg border border-slate-200 bg-brand-dark transition-all group-hover:brightness-110" className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
style={{ style={{
height: `${Math.max(choice.percentage, 2)}%`, height: `${Math.max(choice.percentage, 2)}%`,
opacity, opacity,

View File

@@ -37,7 +37,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
elementSummary.element.allowMulti ? ( elementSummary.element.allowMulti ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_selections", { count: elementSummary.selectionCount })} {`${elementSummary.selectionCount} ${t("common.selections")}`}
</div> </div>
) : undefined ) : undefined
} }
@@ -74,7 +74,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
</div> </div>
<div className="flex w-full space-x-2"> <div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0"> <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 })} {result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p> </p>
<p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700"> <p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}% {convertFloatToNDecimal(result.percentage, 2)}%

View File

@@ -116,7 +116,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
) )
}> }>
<div <div
className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`} className={`bg-brand-dark h-full ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
style={{ opacity }} style={{ opacity }}
/> />
</ClickableBarSegment> </ClickableBarSegment>
@@ -198,7 +198,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: result.count })} {result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} /> <ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
@@ -215,7 +215,8 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
<div className="text flex justify-between px-2"> <div className="text flex justify-between px-2">
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p> <p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary.dismissed.count })} {elementSummary.dismissed.count}{" "}
{elementSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,125 +0,0 @@
"use client";
import { AlertCircleIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TDisplayWithContact } from "@formbricks/types/displays";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { Button } from "@/modules/ui/components/button";
interface SummaryImpressionsProps {
displays: TDisplayWithContact[];
isLoading: boolean;
hasMore: boolean;
displaysError: string | null;
environmentId: string;
locale: TUserLocale;
onLoadMore: () => void;
onRetry: () => void;
}
const getDisplayContactIdentifier = (display: TDisplayWithContact): string => {
if (!display.contact) return "";
return display.contact.attributes?.email || display.contact.attributes?.userId || display.contact.id;
};
export const SummaryImpressions = ({
displays,
isLoading,
hasMore,
displaysError,
environmentId,
locale,
onLoadMore,
onRetry,
}: SummaryImpressionsProps) => {
const { t } = useTranslation();
const renderContent = () => {
if (displaysError) {
return (
<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" />
<span className="text-sm font-medium">{t("common.error_loading_data")}</span>
</div>
<p className="text-sm text-slate-500">{displaysError}</p>
<Button onClick={onRetry} variant="secondary" size="sm">
{t("common.try_again")}
</Button>
</div>
</div>
);
}
if (displays.length === 0) {
return (
<div className="p-8 text-center text-sm text-slate-500">
{t("environments.surveys.summary.no_identified_impressions")}
</div>
);
}
return (
<>
<div className="grid min-h-10 grid-cols-4 items-center border-b border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
<div className="col-span-2 px-4 md:px-6">{t("common.user")}</div>
<div className="col-span-2 px-4 md:px-6">{t("environments.contacts.survey_viewed_at")}</div>
</div>
<div className="max-h-[62vh] overflow-y-auto">
{displays.map((display) => (
<div
key={display.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-xs text-slate-800 last:border-transparent md:text-sm">
<div className="col-span-2 pl-4 md:pl-6">
{display.contact ? (
<Link
className="ph-no-capture break-all text-slate-600 hover:underline"
href={`/environments/${environmentId}/contacts/${display.contact.id}`}>
{getDisplayContactIdentifier(display)}
</Link>
) : (
<span className="break-all text-slate-600">{t("common.anonymous")}</span>
)}
</div>
<div className="col-span-2 px-4 text-slate-500 md:px-6">
{timeSince(display.createdAt.toString(), locale)}
</div>
</div>
))}
</div>
{hasMore && (
<div className="flex justify-center border-t border-slate-100 py-4">
<Button onClick={onLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</>
);
};
if (isLoading) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-8 shadow-sm">
<div className="flex items-center justify-center">
<div className="h-6 w-32 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
);
}
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" />
<span>{t("environments.surveys.summary.impressions_identified_only")}</span>
</div>
{renderContent()}
</div>
);
};

View File

@@ -10,12 +10,12 @@ interface SummaryMetadataProps {
surveySummary: TSurveySummary["meta"]; surveySummary: TSurveySummary["meta"];
quotasCount: number; quotasCount: number;
isLoading: boolean; isLoading: boolean;
tab: "dropOffs" | "quotas" | "impressions" | undefined; tab: "dropOffs" | "quotas" | undefined;
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | "impressions" | undefined>>; setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
isQuotasAllowed: boolean; isQuotasAllowed: boolean;
} }
const formatTime = (ttc: number) => { const formatTime = (ttc) => {
const seconds = ttc / 1000; const seconds = ttc / 1000;
let formattedValue; let formattedValue;
@@ -53,7 +53,7 @@ export const SummaryMetadata = ({
const { t } = useTranslation(); const { t } = useTranslation();
const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount; const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
const handleTabChange = (val: "dropOffs" | "quotas" | "impressions") => { const handleTabChange = (val: "dropOffs" | "quotas") => {
const change = tab === val ? undefined : val; const change = tab === val ? undefined : val;
setTab(change); setTab(change);
}; };
@@ -65,16 +65,12 @@ export const SummaryMetadata = ({
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`, `grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6" isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6"
)}> )}>
<InteractiveCard <StatCard
key="impressions"
tab="impressions"
label={t("environments.surveys.summary.impressions")} label={t("environments.surveys.summary.impressions")}
percentage={null} percentage={null}
value={displayCount === 0 ? <span>-</span> : displayCount} value={displayCount === 0 ? <span>-</span> : displayCount}
tooltipText={t("environments.surveys.summary.impressions_tooltip")} tooltipText={t("environments.surveys.summary.impressions_tooltip")}
isLoading={isLoading} isLoading={isLoading}
onClick={() => handleTabChange("impressions")}
isActive={tab === "impressions"}
/> />
<StatCard <StatCard
label={t("environments.surveys.summary.starts")} label={t("environments.surveys.summary.starts")}

View File

@@ -1,31 +1,21 @@
"use client"; "use client";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TDisplayWithContact } from "@formbricks/types/displays";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types"; import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
getDisplaysWithContactAction,
getSurveySummaryAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context"; import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop"; import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs"; import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
import { SummaryImpressions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryImpressions";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter"; import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { getFormattedFilters } from "@/app/lib/surveys/surveys"; import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { replaceHeadlineRecall } from "@/lib/utils/recall"; import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { QuotasSummary } from "@/modules/ee/quotas/components/quotas-summary"; import { QuotasSummary } from "@/modules/ee/quotas/components/quotas-summary";
import { SummaryList } from "./SummaryList"; import { SummaryList } from "./SummaryList";
import { SummaryMetadata } from "./SummaryMetadata"; import { SummaryMetadata } from "./SummaryMetadata";
const DISPLAYS_PER_PAGE = 15;
const defaultSurveySummary: TSurveySummary = { const defaultSurveySummary: TSurveySummary = {
meta: { meta: {
completedPercentage: 0, completedPercentage: 0,
@@ -61,76 +51,17 @@ export const SummaryPage = ({
initialSurveySummary, initialSurveySummary,
isQuotasAllowed, isQuotasAllowed,
}: SummaryPageProps) => { }: SummaryPageProps) => {
const { t } = useTranslation();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [surveySummary, setSurveySummary] = useState<TSurveySummary>( const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
initialSurveySummary || defaultSurveySummary initialSurveySummary || defaultSurveySummary
); );
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined); const [tab, setTab] = useState<"dropOffs" | "quotas" | undefined>(undefined);
const [isLoading, setIsLoading] = useState(!initialSurveySummary); const [isLoading, setIsLoading] = useState(!initialSurveySummary);
const { selectedFilter, dateRange, resetState } = useResponseFilter(); const { selectedFilter, dateRange, resetState } = useResponseFilter();
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
const [hasMoreDisplays, setHasMoreDisplays] = useState(true);
const [displaysError, setDisplaysError] = useState<string | null>(null);
const displaysFetchedRef = useRef(false);
const fetchDisplays = useCallback(
async (offset: number) => {
const response = await getDisplaysWithContactAction({
surveyId,
limit: DISPLAYS_PER_PAGE,
offset,
});
if (!response?.data) {
const errorMessage = getFormattedErrorMessage(response);
throw new Error(errorMessage);
}
return response?.data ?? [];
},
[surveyId]
);
const loadInitialDisplays = useCallback(async () => {
setIsDisplaysLoading(true);
setDisplaysError(null);
try {
const data = await fetchDisplays(0);
setDisplays(data);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
setDisplays([]);
setHasMoreDisplays(false);
} finally {
setIsDisplaysLoading(false);
}
}, [fetchDisplays, t]);
const handleLoadMoreDisplays = useCallback(async () => {
try {
const data = await fetchDisplays(displays.length);
setDisplays((prev) => [...prev, ...data]);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
toast.error(errorMessage);
}
}, [fetchDisplays, displays.length, t]);
useEffect(() => {
if (tab === "impressions" && !displaysFetchedRef.current) {
displaysFetchedRef.current = true;
loadInitialDisplays();
}
}, [tab, loadInitialDisplays]);
// Only fetch data when filters change or when there's no initial data // Only fetch data when filters change or when there's no initial data
useEffect(() => { useEffect(() => {
// If we have initial data and no filters are applied, don't fetch // If we have initial data and no filters are applied, don't fetch
@@ -190,18 +121,6 @@ export const SummaryPage = ({
setTab={setTab} setTab={setTab}
isQuotasAllowed={isQuotasAllowed} isQuotasAllowed={isQuotasAllowed}
/> />
{tab === "impressions" && (
<SummaryImpressions
displays={displays}
isLoading={isDisplaysLoading}
hasMore={hasMoreDisplays}
displaysError={displaysError}
environmentId={environment.id}
locale={locale}
onLoadMore={handleLoadMoreDisplays}
onRetry={loadInitialDisplays}
/>
)}
{tab === "dropOffs" && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />} {tab === "dropOffs" && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
{isQuotasAllowed && tab === "quotas" && <QuotasSummary quotas={surveySummary.quotas} />} {isQuotasAllowed && tab === "quotas" && <QuotasSummary quotas={surveySummary.quotas} />}
<div className="flex gap-1.5"> <div className="flex gap-1.5">

View File

@@ -4,9 +4,9 @@ import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card"; import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card";
interface InteractiveCardProps { interface InteractiveCardProps {
tab: "dropOffs" | "quotas" | "impressions"; tab: "dropOffs" | "quotas";
label: string; label: string;
percentage: number | null; percentage: number;
value: React.ReactNode; value: React.ReactNode;
tooltipText: string; tooltipText: string;
isLoading: boolean; isLoading: boolean;

View File

@@ -75,7 +75,17 @@ export const ShareSurveyModal = ({
const [showView, setShowView] = useState<ModalView>(modalView); const [showView, setShowView] = useState<ModalView>(modalView);
const { email } = user; const { email } = user;
const { t } = useTranslation(); const { t } = useTranslation();
const linkTabs = useMemo(() => { const linkTabs: {
id: ShareViaType | ShareSettingsType;
type: LinkTabsType;
label: string;
icon: React.ElementType;
title: string;
description: string;
componentType: React.ComponentType<unknown>;
componentProps: unknown;
disabled?: boolean;
}[] = useMemo(() => {
const tabs = [ const tabs = [
{ {
id: ShareViaType.ANON_LINKS, id: ShareViaType.ANON_LINKS,

View File

@@ -352,7 +352,7 @@ export const AnonymousLinksTab = ({
}, },
{ {
title: t("environments.surveys.share.anonymous_links.custom_start_point"), title: t("environments.surveys.share.anonymous_links.custom_start_point"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-block", href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
}, },
]} ]}
/> />

View File

@@ -47,7 +47,6 @@ const createNoCodeConfigType = (t: ReturnType<typeof useTranslation>["t"]) => ({
pageView: t("environments.actions.page_view"), pageView: t("environments.actions.page_view"),
exitIntent: t("environments.actions.exit_intent"), exitIntent: t("environments.actions.exit_intent"),
fiftyPercentScroll: t("environments.actions.fifty_percent_scroll"), fiftyPercentScroll: t("environments.actions.fifty_percent_scroll"),
pageDwell: t("environments.actions.time_on_page"),
}); });
const formatRecontactDaysString = (days: number, t: ReturnType<typeof useTranslation>["t"]) => { const formatRecontactDaysString = (days: number, t: ReturnType<typeof useTranslation>["t"]) => {

View File

@@ -105,7 +105,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
<div className={scriptsMode === "replace" ? "opacity-50" : ""}> <div className={scriptsMode === "replace" ? "opacity-50" : ""}>
<FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel> <FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel>
<div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3"> <div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3">
<pre className="whitespace-pre-wrap font-mono text-xs text-slate-600"> <pre className="font-mono text-xs whitespace-pre-wrap text-slate-600">
{projectCustomScripts} {projectCustomScripts}
</pre> </pre>
</div> </div>
@@ -135,7 +135,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
rows={8} rows={8}
placeholder={t("environments.surveys.share.custom_html.placeholder")} placeholder={t("environments.surveys.share.custom_html.placeholder")}
className={cn( className={cn(
"flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" "focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
)} )}
{...field} {...field}
disabled={isReadOnly} disabled={isReadOnly}

View File

@@ -165,7 +165,7 @@ export const PersonalLinksTab = ({
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")} description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
buttons={[ buttons={[
{ {
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"), text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
href: isFormbricksCloud href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing` ? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license", : "https://formbricks.com/upgrade-self-hosting-license",

View File

@@ -39,7 +39,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
} }
} }
} catch (error) { } catch (error) {
logger.error(error as Error, "Failed to generate QR code"); logger.error("Failed to generate QR code:", error);
setHasError(true); setHasError(true);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -66,7 +66,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
downloadInstance.download({ name: "survey-qr-code", extension: "png" }); downloadInstance.download({ name: "survey-qr-code", extension: "png" });
toast.success(t("environments.surveys.summary.qr_code_download_with_start_soon")); toast.success(t("environments.surveys.summary.qr_code_download_with_start_soon"));
} catch (error) { } catch (error) {
logger.error(error as Error, "Failed to download QR code"); logger.error("Failed to download QR code:", error);
toast.error(t("environments.surveys.summary.qr_code_download_failed")); toast.error(t("environments.surveys.summary.qr_code_download_failed"));
} finally { } finally {
setIsDownloading(false); setIsDownloading(false);

View File

@@ -4,10 +4,6 @@ import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import {
ShareSettingsType,
ShareViaType,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink"; import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
import { Badge } from "@/modules/ui/components/badge"; import { Badge } from "@/modules/ui/components/badge";
@@ -17,9 +13,9 @@ interface SuccessViewProps {
publicDomain: string; publicDomain: string;
setSurveyUrl: (url: string) => void; setSurveyUrl: (url: string) => void;
user: TUser; user: TUser;
tabs: { id: ShareViaType | ShareSettingsType; label: string; icon: React.ElementType }[]; tabs: { id: string; label: string; icon: React.ElementType }[];
handleViewChange: (view: "start" | "share") => void; handleViewChange: (view: string) => void;
handleEmbedViewWithTab: (tabId: ShareViaType | ShareSettingsType) => void; handleEmbedViewWithTab: (tabId: string) => void;
isReadOnly: boolean; isReadOnly: boolean;
} }
@@ -70,7 +66,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8"> className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" /> <UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
{t("environments.surveys.summary.use_personal_links")} {t("environments.surveys.summary.use_personal_links")}
<Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} /> <Badge size="normal" type="success" className="absolute top-3 right-3" text={t("common.new")} />
</button> </button>
<Link <Link
href={`/environments/${environmentId}/settings/notifications`} href={`/environments/${environmentId}/settings/notifications`}

View File

@@ -96,7 +96,7 @@ describe("Tests for getQuotasSummary service", () => {
_count: { _count: {
quotaLinks: 0, quotaLinks: 0,
}, },
} as unknown as Awaited<ReturnType<typeof prisma.surveyQuota.findMany>>[number], },
]); ]);
const result = await getQuotasSummary(surveyId); const result = await getQuotasSummary(surveyId);
@@ -120,7 +120,7 @@ describe("Tests for getQuotasSummary service", () => {
_count: { _count: {
quotaLinks: 0, quotaLinks: 0,
}, },
} as unknown as Awaited<ReturnType<typeof prisma.surveyQuota.findMany>>[number], },
]); ]);
const result = await getQuotasSummary(surveyId); const result = await getQuotasSummary(surveyId);

View File

@@ -662,23 +662,17 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3); expect((summary[0] as any).choices).toHaveLength(3);
// Item 1 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5 // Item 1 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5
const item1 = (summary[0] as any).choices.find( const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1");
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
);
expect(item1.count).toBe(2); expect(item1.count).toBe(2);
expect(item1.avgRanking).toBe(1.5); expect(item1.avgRanking).toBe(1.5);
// Item 2 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5 // Item 2 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5
const item2 = (summary[0] as any).choices.find( const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2");
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
);
expect(item2.count).toBe(2); expect(item2.count).toBe(2);
expect(item2.avgRanking).toBe(1.5); expect(item2.avgRanking).toBe(1.5);
// Item 3 is in position 3 twice, so avg ranking should be 3 // Item 3 is in position 3 twice, so avg ranking should be 3
const item3 = (summary[0] as any).choices.find( const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
);
expect(item3.count).toBe(2); expect(item3.count).toBe(2);
expect(item3.avgRanking).toBe(3); expect(item3.avgRanking).toBe(3);
}); });
@@ -753,23 +747,17 @@ describe("getQuestionSummary", () => {
expect(summary[0].responseCount).toBe(1); expect(summary[0].responseCount).toBe(1);
// Item 1 is in position 2, so avg ranking should be 2 // Item 1 is in position 2, so avg ranking should be 2
const item1 = (summary[0] as any).choices.find( const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1");
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
);
expect(item1.count).toBe(1); expect(item1.count).toBe(1);
expect(item1.avgRanking).toBe(2); expect(item1.avgRanking).toBe(2);
// Item 2 is in position 1, so avg ranking should be 1 // Item 2 is in position 1, so avg ranking should be 1
const item2 = (summary[0] as any).choices.find( const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2");
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
);
expect(item2.count).toBe(1); expect(item2.count).toBe(1);
expect(item2.avgRanking).toBe(1); expect(item2.avgRanking).toBe(1);
// Item 3 is in position 3, so avg ranking should be 3 // Item 3 is in position 3, so avg ranking should be 3
const item3 = (summary[0] as any).choices.find( const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
);
expect(item3.count).toBe(1); expect(item3.count).toBe(1);
expect(item3.avgRanking).toBe(3); expect(item3.avgRanking).toBe(3);
}); });
@@ -842,12 +830,10 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3); expect((summary[0] as any).choices).toHaveLength(3);
// All items should have count 0 and avgRanking 0 // All items should have count 0 and avgRanking 0
(summary[0] as any).choices.forEach( (summary[0] as any).choices.forEach((choice) => {
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => { expect(choice.count).toBe(0);
expect(choice.count).toBe(0); expect(choice.avgRanking).toBe(0);
expect(choice.avgRanking).toBe(0); });
}
);
}); });
test("getQuestionSummary handles ranking question with non-array answers", async () => { test("getQuestionSummary handles ranking question with non-array answers", async () => {
@@ -908,12 +894,10 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3); expect((summary[0] as any).choices).toHaveLength(3);
// All items should have count 0 and avgRanking 0 since we had no valid ranking data // All items should have count 0 and avgRanking 0 since we had no valid ranking data
(summary[0] as any).choices.forEach( (summary[0] as any).choices.forEach((choice) => {
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => { expect(choice.count).toBe(0);
expect(choice.count).toBe(0); expect(choice.avgRanking).toBe(0);
expect(choice.avgRanking).toBe(0); });
}
);
}); });
test("getQuestionSummary handles ranking question with values not in choices", async () => { test("getQuestionSummary handles ranking question with values not in choices", async () => {
@@ -974,23 +958,17 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3); expect((summary[0] as any).choices).toHaveLength(3);
// Item 1 is in position 1, so avg ranking should be 1 // Item 1 is in position 1, so avg ranking should be 1
const item1 = (summary[0] as any).choices.find( const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1");
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
);
expect(item1.count).toBe(1); expect(item1.count).toBe(1);
expect(item1.avgRanking).toBe(1); expect(item1.avgRanking).toBe(1);
// Item 2 was not ranked, so should have count 0 and avgRanking 0 // Item 2 was not ranked, so should have count 0 and avgRanking 0
const item2 = (summary[0] as any).choices.find( const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2");
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
);
expect(item2.count).toBe(0); expect(item2.count).toBe(0);
expect(item2.avgRanking).toBe(0); expect(item2.avgRanking).toBe(0);
// Item 3 is in position 3, so avg ranking should be 3 // Item 3 is in position 3, so avg ranking should be 3
const item3 = (summary[0] as any).choices.find( const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
);
expect(item3.count).toBe(1); expect(item3.count).toBe(1);
expect(item3.avgRanking).toBe(3); expect(item3.avgRanking).toBe(3);
}); });
@@ -1008,11 +986,7 @@ describe("getSurveySummary", () => {
// Since getSurveySummary calls getResponsesForSummary internally, we'll mock prisma.response.findMany // Since getSurveySummary calls getResponsesForSummary internally, we'll mock prisma.response.findMany
// which is used by the actual implementation of getResponsesForSummary. // which is used by the actual implementation of getResponsesForSummary.
vi.mocked(prisma.response.findMany).mockResolvedValue( vi.mocked(prisma.response.findMany).mockResolvedValue(
mockResponses.map((r: Record<string, unknown>) => ({ mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any
...r,
contactId: null,
personAttributes: {},
})) as any
); );
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(10); vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(10);
@@ -1046,8 +1020,8 @@ describe("getSurveySummary", () => {
test("handles filterCriteria", async () => { test("handles filterCriteria", async () => {
const filterCriteria: TResponseFilterCriteria = { finished: true }; const filterCriteria: TResponseFilterCriteria = { finished: true };
const finishedResponses = mockResponses const finishedResponses = mockResponses
.filter((r: Record<string, unknown>) => r.finished) .filter((r) => r.finished)
.map((r: Record<string, unknown>) => ({ ...r, contactId: null, personAttributes: {} })); .map((r) => ({ ...r, contactId: null, personAttributes: {} }));
vi.mocked(prisma.response.findMany).mockResolvedValue(finishedResponses as any); vi.mocked(prisma.response.findMany).mockResolvedValue(finishedResponses as any);
await getSurveySummary(mockSurveyId, filterCriteria); await getSurveySummary(mockSurveyId, filterCriteria);
@@ -1069,11 +1043,7 @@ describe("getResponsesForSummary", () => {
vi.resetAllMocks(); vi.resetAllMocks();
vi.mocked(getSurvey).mockResolvedValue(mockBaseSurvey); vi.mocked(getSurvey).mockResolvedValue(mockBaseSurvey);
vi.mocked(prisma.response.findMany).mockResolvedValue( vi.mocked(prisma.response.findMany).mockResolvedValue(
mockResponses.map((r: Record<string, unknown>) => ({ mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any
...r,
contactId: null,
personAttributes: {},
})) as any
); );
// React cache is already mocked globally - no need to mock it again // React cache is already mocked globally - no need to mock it again
}); });
@@ -1872,63 +1842,23 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(2); expect(summary[0].responseCount).toBe(2);
// Verify Speed row // Verify Speed row
const speedRow = summary[0].data.find( const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(2); expect(speedRow.totalResponsesForRow).toBe(2);
expect(speedRow.columnPercentages).toHaveLength(4); // 4 columns expect(speedRow.columnPercentages).toHaveLength(4); // 4 columns
expect( expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good") expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
.percentage
).toBe(50);
expect(
speedRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(50);
// Verify Quality row // Verify Quality row
const qualityRow = summary[0].data.find( const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(2); expect(qualityRow.totalResponsesForRow).toBe(2);
expect( expect(qualityRow.columnPercentages.find((col) => col.column === "Excellent").percentage).toBe(50);
qualityRow.columnPercentages.find( expect(qualityRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
(col: { column: string; percentage: number }) => col.column === "Excellent"
).percentage
).toBe(50);
expect(
qualityRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Good"
).percentage
).toBe(50);
// Verify Price row // Verify Price row
const priceRow = summary[0].data.find( const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
expect(priceRow.totalResponsesForRow).toBe(2); expect(priceRow.totalResponsesForRow).toBe(2);
expect( expect(priceRow.columnPercentages.find((col) => col.column === "Poor").percentage).toBe(50);
priceRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Poor") expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
.percentage
).toBe(50);
expect(
priceRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(50);
}); });
test("getQuestionSummary correctly processes Matrix question with non-default language responses", async () => { test("getQuestionSummary correctly processes Matrix question with non-default language responses", async () => {
@@ -2019,48 +1949,19 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(1); expect(summary[0].responseCount).toBe(1);
// Verify Speed row with localized values mapped to default language // Verify Speed row with localized values mapped to default language
const speedRow = summary[0].data.find( const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(1); expect(speedRow.totalResponsesForRow).toBe(1);
expect( expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
// Verify Quality row // Verify Quality row
const qualityRow = summary[0].data.find( const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(1); expect(qualityRow.totalResponsesForRow).toBe(1);
expect( expect(qualityRow.columnPercentages.find((col) => col.column === "Excellent").percentage).toBe(100);
qualityRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Excellent"
).percentage
).toBe(100);
// Verify Price row // Verify Price row
const priceRow = summary[0].data.find( const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
expect(priceRow.totalResponsesForRow).toBe(1); expect(priceRow.totalResponsesForRow).toBe(1);
expect( expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(100);
priceRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(100);
}); });
test("getQuestionSummary handles missing or invalid data for Matrix questions", async () => { test("getQuestionSummary handles missing or invalid data for Matrix questions", async () => {
@@ -2154,18 +2055,12 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(3); // Count is 3 because responses 2, 3, and 4 have the "matrix-q1" property expect(summary[0].responseCount).toBe(3); // Count is 3 because responses 2, 3, and 4 have the "matrix-q1" property
// All rows should have zero responses for all columns // All rows should have zero responses for all columns
summary[0].data.forEach( summary[0].data.forEach((row) => {
(row: { expect(row.totalResponsesForRow).toBe(0);
rowLabel: string; row.columnPercentages.forEach((col) => {
totalResponsesForRow: number; expect(col.percentage).toBe(0);
columnPercentages: { column: string; percentage: number }[]; });
}) => { });
expect(row.totalResponsesForRow).toBe(0);
row.columnPercentages.forEach((col: { column: string; percentage: number }) => {
expect(col.percentage).toBe(0);
});
}
);
}); });
test("getQuestionSummary handles partial and incomplete matrix responses", async () => { test("getQuestionSummary handles partial and incomplete matrix responses", async () => {
@@ -2252,59 +2147,22 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(2); expect(summary[0].responseCount).toBe(2);
// Verify Speed row - both responses provided data // Verify Speed row - both responses provided data
const speedRow = summary[0].data.find( const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(2); expect(speedRow.totalResponsesForRow).toBe(2);
expect( expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good") expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
.percentage
).toBe(50);
expect(
speedRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(50);
// Verify Quality row - only one response provided data // Verify Quality row - only one response provided data
const qualityRow = summary[0].data.find( const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(1); expect(qualityRow.totalResponsesForRow).toBe(1);
expect( expect(qualityRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
qualityRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Good"
).percentage
).toBe(100);
// Verify Price row - both responses provided data // Verify Price row - both responses provided data
const priceRow = summary[0].data.find( const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
expect(priceRow.totalResponsesForRow).toBe(2); expect(priceRow.totalResponsesForRow).toBe(2);
// ExtraRow should not appear in the summary // ExtraRow should not appear in the summary
expect( expect(summary[0].data.find((row) => row.rowLabel === "ExtraRow")).toBeUndefined();
summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "ExtraRow"
)
).toBeUndefined();
}); });
test("getQuestionSummary handles zero responses for Matrix question correctly", async () => { test("getQuestionSummary handles zero responses for Matrix question correctly", async () => {
@@ -2363,18 +2221,12 @@ describe("Matrix question type tests", () => {
// All rows should have proper structure but zero counts // All rows should have proper structure but zero counts
expect(summary[0].data).toHaveLength(2); // 2 rows expect(summary[0].data).toHaveLength(2); // 2 rows
summary[0].data.forEach( summary[0].data.forEach((row) => {
(row: { expect(row.columnPercentages).toHaveLength(2); // 2 columns
rowLabel: string; expect(row.totalResponsesForRow).toBe(0);
totalResponsesForRow: number; expect(row.columnPercentages[0].percentage).toBe(0);
columnPercentages: { column: string; percentage: number }[]; expect(row.columnPercentages[1].percentage).toBe(0);
}) => { });
expect(row.columnPercentages).toHaveLength(2); // 2 columns
expect(row.totalResponsesForRow).toBe(0);
expect(row.columnPercentages[0].percentage).toBe(0);
expect(row.columnPercentages[1].percentage).toBe(0);
}
);
}); });
test("getQuestionSummary handles Matrix question with mixed valid and invalid column values", async () => { test("getQuestionSummary handles Matrix question with mixed valid and invalid column values", async () => {
@@ -2444,46 +2296,21 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(1); expect(summary[0].responseCount).toBe(1);
// Speed row should have a valid response // Speed row should have a valid response
const speedRow = summary[0].data.find( const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(1); expect(speedRow.totalResponsesForRow).toBe(1);
expect( expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
// Quality row should have no valid responses // Quality row should have no valid responses
const qualityRow = summary[0].data.find( const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(0); expect(qualityRow.totalResponsesForRow).toBe(0);
qualityRow.columnPercentages.forEach((col: { column: string; percentage: number }) => { qualityRow.columnPercentages.forEach((col) => {
expect(col.percentage).toBe(0); expect(col.percentage).toBe(0);
}); });
// Price row should have a valid response // Price row should have a valid response
const priceRow = summary[0].data.find( const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
expect(priceRow.totalResponsesForRow).toBe(1); expect(priceRow.totalResponsesForRow).toBe(1);
expect( expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(100);
priceRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(100);
}); });
test("getQuestionSummary handles Matrix question with invalid row labels", async () => { test("getQuestionSummary handles Matrix question with invalid row labels", async () => {
@@ -2554,48 +2381,17 @@ describe("Matrix question type tests", () => {
expect(summary[0].data).toHaveLength(2); // 2 rows expect(summary[0].data).toHaveLength(2); // 2 rows
// Speed row should have a valid response // Speed row should have a valid response
const speedRow = summary[0].data.find( const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(1); expect(speedRow.totalResponsesForRow).toBe(1);
expect( expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
// Quality row should have no responses // Quality row should have no responses
const qualityRow = summary[0].data.find( const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(0); expect(qualityRow.totalResponsesForRow).toBe(0);
// Invalid rows should not appear in the summary // Invalid rows should not appear in the summary
expect( expect(summary[0].data.find((row) => row.rowLabel === "InvalidRow")).toBeUndefined();
summary[0].data.find( expect(summary[0].data.find((row) => row.rowLabel === "AnotherInvalidRow")).toBeUndefined();
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "InvalidRow"
)
).toBeUndefined();
expect(
summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "AnotherInvalidRow"
)
).toBeUndefined();
}); });
test("getQuestionSummary handles Matrix question with mixed language responses", async () => { test("getQuestionSummary handles Matrix question with mixed language responses", async () => {
@@ -2697,27 +2493,12 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(2); expect(summary[0].responseCount).toBe(2);
// Speed row should have both responses // Speed row should have both responses
const speedRow = summary[0].data.find( const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(2); expect(speedRow.totalResponsesForRow).toBe(2);
expect( expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
// Quality row should have no responses // Quality row should have no responses
const qualityRow = summary[0].data.find( const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(0); expect(qualityRow.totalResponsesForRow).toBe(0);
}); });
@@ -2776,18 +2557,12 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(0); // Counts as response even with null data expect(summary[0].responseCount).toBe(0); // Counts as response even with null data
// Both rows should have zero responses // Both rows should have zero responses
summary[0].data.forEach( summary[0].data.forEach((row) => {
(row: { expect(row.totalResponsesForRow).toBe(0);
rowLabel: string; row.columnPercentages.forEach((col) => {
totalResponsesForRow: number; expect(col.percentage).toBe(0);
columnPercentages: { column: string; percentage: number }[]; });
}) => { });
expect(row.totalResponsesForRow).toBe(0);
row.columnPercentages.forEach((col: { column: string; percentage: number }) => {
expect(col.percentage).toBe(0);
});
}
);
}); });
}); });
@@ -3219,33 +2994,23 @@ describe("Rating question type tests", () => {
expect(summary[0].average).toBe(4.25); expect(summary[0].average).toBe(4.25);
// Verify each rating option count and percentage // Verify each rating option count and percentage
const rating5 = summary[0].choices.find( const rating5 = summary[0].choices.find((c) => c.rating === 5);
(c: { rating: number; count: number; percentage: number }) => c.rating === 5
);
expect(rating5.count).toBe(2); expect(rating5.count).toBe(2);
expect(rating5.percentage).toBe(50); // 2/4 * 100 expect(rating5.percentage).toBe(50); // 2/4 * 100
const rating4 = summary[0].choices.find( const rating4 = summary[0].choices.find((c) => c.rating === 4);
(c: { rating: number; count: number; percentage: number }) => c.rating === 4
);
expect(rating4.count).toBe(1); expect(rating4.count).toBe(1);
expect(rating4.percentage).toBe(25); // 1/4 * 100 expect(rating4.percentage).toBe(25); // 1/4 * 100
const rating3 = summary[0].choices.find( const rating3 = summary[0].choices.find((c) => c.rating === 3);
(c: { rating: number; count: number; percentage: number }) => c.rating === 3
);
expect(rating3.count).toBe(1); expect(rating3.count).toBe(1);
expect(rating3.percentage).toBe(25); // 1/4 * 100 expect(rating3.percentage).toBe(25); // 1/4 * 100
const rating2 = summary[0].choices.find( const rating2 = summary[0].choices.find((c) => c.rating === 2);
(c: { rating: number; count: number; percentage: number }) => c.rating === 2
);
expect(rating2.count).toBe(0); expect(rating2.count).toBe(0);
expect(rating2.percentage).toBe(0); expect(rating2.percentage).toBe(0);
const rating1 = summary[0].choices.find( const rating1 = summary[0].choices.find((c) => c.rating === 1);
(c: { rating: number; count: number; percentage: number }) => c.rating === 1
);
expect(rating1.count).toBe(0); expect(rating1.count).toBe(0);
expect(rating1.percentage).toBe(0); expect(rating1.percentage).toBe(0);
@@ -3389,12 +3154,10 @@ describe("Rating question type tests", () => {
expect(summary[0].average).toBe(0); expect(summary[0].average).toBe(0);
// Verify all ratings have 0 count and percentage // Verify all ratings have 0 count and percentage
summary[0].choices.forEach( summary[0].choices.forEach((choice) => {
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => { expect(choice.count).toBe(0);
expect(choice.count).toBe(0); expect(choice.percentage).toBe(0);
expect(choice.percentage).toBe(0); });
}
);
// Verify dismissed is 0 // Verify dismissed is 0
expect(summary[0].dismissed.count).toBe(0); expect(summary[0].dismissed.count).toBe(0);
@@ -3469,21 +3232,15 @@ describe("PictureSelection question type tests", () => {
expect(summary[0].selectionCount).toBe(3); // Total selections: img1, img2, img3 expect(summary[0].selectionCount).toBe(3); // Total selections: img1, img2, img3
// Check individual choice counts // Check individual choice counts
const img1 = summary[0].choices.find( const img1 = summary[0].choices.find((c) => c.id === "img1");
(c: { id: string; count: number; percentage: number }) => c.id === "img1"
);
expect(img1.count).toBe(1); expect(img1.count).toBe(1);
expect(img1.percentage).toBe(50); expect(img1.percentage).toBe(50);
const img2 = summary[0].choices.find( const img2 = summary[0].choices.find((c) => c.id === "img2");
(c: { id: string; count: number; percentage: number }) => c.id === "img2"
);
expect(img2.count).toBe(1); expect(img2.count).toBe(1);
expect(img2.percentage).toBe(50); expect(img2.percentage).toBe(50);
const img3 = summary[0].choices.find( const img3 = summary[0].choices.find((c) => c.id === "img3");
(c: { id: string; count: number; percentage: number }) => c.id === "img3"
);
expect(img3.count).toBe(1); expect(img3.count).toBe(1);
expect(img3.percentage).toBe(50); expect(img3.percentage).toBe(50);
}); });
@@ -3554,12 +3311,10 @@ describe("PictureSelection question type tests", () => {
expect(summary[0].selectionCount).toBe(0); expect(summary[0].selectionCount).toBe(0);
// All choices should have zero count // All choices should have zero count
summary[0].choices.forEach( summary[0].choices.forEach((choice) => {
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => { expect(choice.count).toBe(0);
expect(choice.count).toBe(0); expect(choice.percentage).toBe(0);
expect(choice.percentage).toBe(0); });
}
);
}); });
test("getQuestionSummary handles PictureSelection with invalid choice ids", async () => { test("getQuestionSummary handles PictureSelection with invalid choice ids", async () => {
@@ -3618,23 +3373,17 @@ describe("PictureSelection question type tests", () => {
expect(summary[0].selectionCount).toBe(2); // Total selections including invalid one expect(summary[0].selectionCount).toBe(2); // Total selections including invalid one
// img1 should be counted // img1 should be counted
const img1 = summary[0].choices.find( const img1 = summary[0].choices.find((c) => c.id === "img1");
(c: { id: string; count: number; percentage: number }) => c.id === "img1"
);
expect(img1.count).toBe(1); expect(img1.count).toBe(1);
expect(img1.percentage).toBe(100); expect(img1.percentage).toBe(100);
// img2 should not be counted // img2 should not be counted
const img2 = summary[0].choices.find( const img2 = summary[0].choices.find((c) => c.id === "img2");
(c: { id: string; count: number; percentage: number }) => c.id === "img2"
);
expect(img2.count).toBe(0); expect(img2.count).toBe(0);
expect(img2.percentage).toBe(0); expect(img2.percentage).toBe(0);
// Invalid ID should not appear in choices // Invalid ID should not appear in choices
expect( expect(summary[0].choices.find((c) => c.id === "invalid-id")).toBeUndefined();
summary[0].choices.find((c: { id: string; count: number; percentage: number }) => c.id === "invalid-id")
).toBeUndefined();
}); });
}); });

View File

@@ -14,7 +14,11 @@ import {
TResponseVariables, TResponseVariables,
ZResponseFilterCriteria, ZResponseFilterCriteria,
} from "@formbricks/types/responses"; } from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import {
TSurveyElement,
TSurveyElementChoice,
TSurveyElementTypeEnum,
} from "@formbricks/types/surveys/elements";
import { import {
TSurvey, TSurvey,
TSurveyElementSummaryAddress, TSurveyElementSummaryAddress,
@@ -289,10 +293,7 @@ const checkForI18n = (
) => { ) => {
const element = elements.find((element) => element.id === id); const element = elements.find((element) => element.id === id);
if ( if (element?.type === "multipleChoiceMulti" || element?.type === "ranking") {
element?.type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
element?.type === TSurveyElementTypeEnum.Ranking
) {
// Initialize an array to hold the choice values // Initialize an array to hold the choice values
let choiceValues = [] as string[]; let choiceValues = [] as string[];
@@ -317,9 +318,13 @@ const checkForI18n = (
} }
// Return the localized value of the choice fo multiSelect single element // Return the localized value of the choice fo multiSelect single element
if (element?.type === TSurveyElementTypeEnum.MultipleChoiceSingle) { if (element && "choices" in element) {
const choice = element.choices?.find((choice) => choice.label[languageCode] === responseData[id]); const choice = element.choices?.find(
return choice ? getLocalizedValue(choice.label, "default") || responseData[id] : responseData[id]; (choice: TSurveyElementChoice) => choice.label?.[languageCode] === responseData[id]
);
return choice && "label" in choice
? getLocalizedValue(choice.label, "default") || responseData[id]
: responseData[id];
} }
return responseData[id]; return responseData[id];
@@ -827,19 +832,13 @@ export const getElementSummary = async (
let totalResponseCount = 0; let totalResponseCount = 0;
// Initialize count object // Initialize count object
const countMap: Record<string, Record<string, number>> = rows.reduce( const countMap: Record<string, string> = rows.reduce((acc, row) => {
(acc: Record<string, Record<string, number>>, row) => { acc[row] = columns.reduce((colAcc, col) => {
acc[row] = columns.reduce( colAcc[col] = 0;
(colAcc: Record<string, number>, col) => { return colAcc;
colAcc[col] = 0; }, {});
return colAcc; return acc;
}, }, {});
{} as Record<string, number>
);
return acc;
},
{} as Record<string, Record<string, number>>
);
responses.forEach((response) => { responses.forEach((response) => {
const selectedResponses = response.data[element.id] as Record<string, string>; const selectedResponses = response.data[element.id] as Record<string, string>;
@@ -1096,7 +1095,7 @@ export const getResponsesForSummary = reactCache(
[limit, ZOptionalNumber], [limit, ZOptionalNumber],
[offset, ZOptionalNumber], [offset, ZOptionalNumber],
[filterCriteria, ZResponseFilterCriteria.optional()], [filterCriteria, ZResponseFilterCriteria.optional()],
[cursor, z.cuid2().optional()] [cursor, z.string().cuid2().optional()]
); );
const queryLimit = limit ?? RESPONSES_PER_PAGE; const queryLimit = limit ?? RESPONSES_PER_PAGE;

View File

@@ -40,11 +40,10 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
if (!user) { if (!user) {
throw new Error(t("common.user_not_found")); throw new Error(t("common.user_not_found"));
} }
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id); const isContactsEnabled = await getIsContactsEnabled();
const isContactsEnabled = await getIsContactsEnabled(organizationId);
const segments = isContactsEnabled ? await getSegments(environment.id) : []; const segments = isContactsEnabled ? await getSegments(environment.id) : [];
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
if (!organizationId) { if (!organizationId) {
throw new Error(t("common.organization_not_found")); throw new Error(t("common.organization_not_found"));
} }
@@ -52,7 +51,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
if (!organizationBilling) { if (!organizationBilling) {
throw new Error(t("common.organization_not_found")); throw new Error(t("common.organization_not_found"));
} }
const isQuotasAllowed = await getIsQuotasEnabled(organizationId); const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration // Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
const initialSurveySummary = await getSurveySummary(surveyId); const initialSurveySummary = await getSurveySummary(surveyId);

View File

@@ -4,16 +4,18 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses"; import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { ZSurvey } from "@formbricks/types/surveys/types"; import { TSurvey, ZSurvey } from "@formbricks/types/surveys/types";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service"; import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service"; import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service"; import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper"; import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
import { getQuotas } from "@/modules/ee/quotas/lib/quotas"; import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission"; import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
@@ -26,7 +28,7 @@ const ZGetResponsesDownloadUrlAction = z.object({
}); });
export const getResponsesDownloadUrlAction = authenticatedActionClient export const getResponsesDownloadUrlAction = authenticatedActionClient
.inputSchema(ZGetResponsesDownloadUrlAction) .schema(ZGetResponsesDownloadUrlAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -56,7 +58,7 @@ const ZGetSurveyFilterDataAction = z.object({
}); });
export const getSurveyFilterDataAction = authenticatedActionClient export const getSurveyFilterDataAction = authenticatedActionClient
.inputSchema(ZGetSurveyFilterDataAction) .schema(ZGetSurveyFilterDataAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
const survey = await getSurvey(parsedInput.surveyId); const survey = await getSurvey(parsedInput.surveyId);
@@ -87,7 +89,7 @@ export const getSurveyFilterDataAction = authenticatedActionClient
throw new ResourceNotFoundError("Organization", organizationId); throw new ResourceNotFoundError("Organization", organizationId);
} }
const isQuotasAllowed = await getIsQuotasEnabled(organizationId); const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const [tags, { contactAttributes: attributes, meta, hiddenFields }, quotas = []] = await Promise.all([ const [tags, { contactAttributes: attributes, meta, hiddenFields }, quotas = []] = await Promise.all([
getTagsByEnvironmentId(survey.environmentId), getTagsByEnvironmentId(survey.environmentId),
@@ -113,52 +115,60 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
throw new ResourceNotFoundError("Organization not found", organizationId); throw new ResourceNotFoundError("Organization not found", organizationId);
} }
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId); const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
if (!isSurveyFollowUpsEnabled) { if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization"); throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
} }
}; };
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action( export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => { withAuditLogging(
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id); "updated",
await checkAuthorizationUpdated({ "survey",
userId: ctx.user?.id ?? "", async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: TSurvey }) => {
organizationId, const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
access: [ await checkAuthorizationUpdated({
{ userId: ctx.user?.id ?? "",
type: "organization", organizationId,
roles: ["owner", "manager"], access: [
}, {
{ type: "organization",
type: "projectTeam", roles: ["owner", "manager"],
projectId: await getProjectIdFromSurveyId(parsedInput.id), },
minPermission: "readWrite", {
}, type: "projectTeam",
], projectId: await getProjectIdFromSurveyId(parsedInput.id),
}); minPermission: "readWrite",
},
],
});
const { followUps } = parsedInput; const { followUps } = parsedInput;
const oldSurvey = await getSurvey(parsedInput.id); const oldSurvey = await getSurvey(parsedInput.id);
if (parsedInput.recaptcha?.enabled) { if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId); await checkSpamProtectionPermission(organizationId);
}
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
if (parsedInput.languages?.length) {
await checkMultiLanguagePermission(organizationId);
}
// Context for audit log
ctx.auditLoggingCtx.surveyId = parsedInput.id;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.oldObject = oldSurvey;
const newSurvey = await updateSurvey(parsedInput);
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
} }
)
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
// Context for audit log
ctx.auditLoggingCtx.surveyId = parsedInput.id;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.oldObject = oldSurvey;
const newSurvey = await updateSurvey(parsedInput);
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
})
); );

View File

@@ -164,12 +164,12 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
const datePickerRef = useRef<HTMLDivElement>(null); const datePickerRef = useRef<HTMLDivElement>(null);
const extractMetadataKeys = useCallback((obj: Record<string, unknown>, parentKey = "") => { const extractMetadataKeys = useCallback((obj, parentKey = "") => {
let keys: string[] = []; let keys: string[] = [];
for (let key in obj) { for (let key in obj) {
if (typeof obj[key] === "object" && obj[key] !== null) { if (typeof obj[key] === "object" && obj[key] !== null) {
keys = keys.concat(extractMetadataKeys(obj[key] as Record<string, unknown>, parentKey + key + " - ")); keys = keys.concat(extractMetadataKeys(obj[key], parentKey + key + " - "));
} else { } else {
keys.push(parentKey + key); keys.push(parentKey + key);
} }

View File

@@ -113,9 +113,7 @@ const elementIcons = {
}; };
const getIcon = (type: string) => { const getIcon = (type: string) => {
const IconComponent = (elementIcons as Record<string, (typeof elementIcons)[keyof typeof elementIcons]>)[ const IconComponent = elementIcons[type];
type
];
return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null; return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null;
}; };
@@ -194,7 +192,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
value={inputValue} value={inputValue}
onValueChange={setInputValue} onValueChange={setInputValue}
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")} placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0" className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none ring-offset-transparent outline-none focus:border-none focus:shadow-none focus:ring-offset-0 focus:outline-none"
/> />
)} )}
<Button <Button

View File

@@ -198,7 +198,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
}; };
setFilterValue({ ...filterValue }); setFilterValue({ ...filterValue });
}; };
const handleRemoveMultiSelect = (value: string[], index: number) => { const handleRemoveMultiSelect = (value: string[], index) => {
filterValue.filter[index] = { filterValue.filter[index] = {
...filterValue.filter[index], ...filterValue.filter[index],
filterType: { filterType: {
@@ -241,7 +241,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
<Popover open={isOpen} onOpenChange={handleOpenChange}> <Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<PopoverTriggerButton isOpen={isOpen}> <PopoverTriggerButton isOpen={isOpen}>
{t("common.filter")} <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b> Filter <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
</PopoverTriggerButton> </PopoverTriggerButton>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
@@ -329,7 +329,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
</div> </div>
{i !== filterValue.filter.length - 1 && ( {i !== filterValue.filter.length - 1 && (
<div className="my-4 flex items-center"> <div className="my-4 flex items-center">
<p className="mr-4 font-semibold text-slate-800">{t("common.and")}</p> <p className="mr-4 font-semibold text-slate-800">and</p>
<hr className="w-full text-slate-600" /> <hr className="w-full text-slate-600" />
</div> </div>
)} )}

View File

@@ -34,27 +34,23 @@ export const SurveyStatusDropdown = ({
const updateSurveyActionResponse = await updateSurveyAction({ ...survey, status }); const updateSurveyActionResponse = await updateSurveyAction({ ...survey, status });
if (updateSurveyActionResponse?.data) { if (updateSurveyActionResponse?.data) {
const resultingStatus = updateSurveyActionResponse.data.status; toast.success(
const statusToToastMessage: Partial<Record<TSurvey["status"], string>> = { status === "inProgress"
inProgress: t("common.survey_live"), ? t("common.survey_live")
paused: t("common.survey_paused"), : status === "paused"
completed: t("common.survey_completed"), ? t("common.survey_paused")
}; : status === "completed"
? t("common.survey_completed")
const toastMessage = statusToToastMessage[resultingStatus]; : ""
if (toastMessage) { );
toast.success(toastMessage);
}
if (updateLocalSurveyStatus) {
updateLocalSurveyStatus(resultingStatus);
}
router.refresh(); router.refresh();
} else { } else {
const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse); const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse);
toast.error(errorMessage); toast.error(errorMessage);
} }
if (updateLocalSurveyStatus) updateLocalSurveyStatus(status);
}; };
return ( return (

View File

@@ -1,6 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
const Page = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => { const Page = async (props) => {
const params = await props.params; const params = await props.params;
return redirect(`/environments/${params.environmentId}/surveys/${params.surveyId}/summary`); return redirect(`/environments/${params.environmentId}/surveys/${params.surveyId}/summary`);
}; };

View File

@@ -1,208 +0,0 @@
"use client";
import { CheckCircle2, Sparkles } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
const FORMBRICKS_HOST = "https://app.formbricks.com";
const SURVEY_ID = "cr9r4b2r73x6hlmn5aa2ha44";
const ENVIRONMENT_ID = "cmk41i8bi92bdad01svi74dec";
interface WorkflowsPageProps {
userEmail: string;
organizationName: string;
billingPlan: string;
}
type Step = "prompt" | "followup" | "thankyou";
export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: WorkflowsPageProps) => {
const { t } = useTranslation();
const [step, setStep] = useState<Step>("prompt");
const [promptValue, setPromptValue] = useState("");
const [detailsValue, setDetailsValue] = useState("");
const [responseId, setResponseId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleGenerateWorkflow = async () => {
if (promptValue.trim().length < 100 || isSubmitting) return;
setIsSubmitting(true);
try {
const res = await fetch(`${FORMBRICKS_HOST}/api/v2/client/${ENVIRONMENT_ID}/responses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
surveyId: SURVEY_ID,
finished: false,
data: {
workflow: promptValue.trim(),
useremail: userEmail,
orgname: organizationName,
billingplan: billingPlan,
},
}),
});
if (res.ok) {
const json = await res.json();
setResponseId(json.data?.id ?? null);
}
setStep("followup");
} catch {
setStep("followup");
} finally {
setIsSubmitting(false);
}
};
const handleSubmitFeedback = async () => {
if (isSubmitting) return;
setIsSubmitting(true);
if (responseId) {
try {
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
finished: true,
data: {
details: detailsValue.trim(),
},
}),
});
} catch {
// silently fail
}
}
setIsSubmitting(false);
setStep("thankyou");
};
const handleSkipFeedback = async () => {
if (!responseId) {
setStep("thankyou");
return;
}
try {
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
finished: true,
data: {},
}),
});
} catch {
// silently fail
}
setStep("thankyou");
};
if (step === "prompt") {
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-brand-light to-brand-dark shadow-md">
<Sparkles className="h-6 w-6 text-white" />
</div>
<h1 className="text-4xl font-bold tracking-tight text-slate-800">{t("workflows.heading")}</h1>
<p className="text-lg text-slate-500">{t("workflows.subheading")}</p>
</div>
<div className="relative">
<textarea
value={promptValue}
onChange={(e) => setPromptValue(e.target.value)}
placeholder={t("workflows.placeholder")}
rows={5}
className="w-full resize-none rounded-xl border border-slate-200 bg-white px-5 py-4 text-base text-slate-800 shadow-sm transition-all placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-brand-light/20"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
handleGenerateWorkflow();
}
}}
/>
<div className="mt-3 flex items-center justify-between">
<span
className={`text-xs ${promptValue.trim().length >= 100 ? "text-slate-400" : "text-amber-500"}`}>
{promptValue.trim().length} / 100
</span>
<Button
onClick={handleGenerateWorkflow}
disabled={promptValue.trim().length < 100 || isSubmitting}
loading={isSubmitting}
size="lg">
<Sparkles className="h-4 w-4" />
{t("workflows.generate_button")}
</Button>
</div>
</div>
</div>
</div>
);
}
if (step === "followup") {
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100">
<Sparkles className="h-6 w-6 text-brand-dark" />
</div>
<h1 className="text-3xl font-bold tracking-tight text-slate-800">
{t("workflows.coming_soon_title")}
</h1>
<p className="mx-auto max-w-md text-base text-slate-500">
{t("workflows.coming_soon_description")}
</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<label className="text-md mb-2 block font-medium text-slate-700">
{t("workflows.follow_up_label")}
</label>
<textarea
value={detailsValue}
onChange={(e) => setDetailsValue(e.target.value)}
placeholder={t("workflows.follow_up_placeholder")}
rows={4}
className="w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-800 transition-all placeholder:text-slate-400 focus:border-brand-dark focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-light/20"
/>
<div className="mt-4 flex items-center justify-end gap-3">
<Button variant="ghost" onClick={handleSkipFeedback} className="text-slate-500">
{t("common.skip")}
</Button>
<Button
onClick={handleSubmitFeedback}
disabled={!detailsValue.trim() || isSubmitting}
loading={isSubmitting}>
{t("workflows.submit_button")}
</Button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-md space-y-6 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-50">
<CheckCircle2 className="h-8 w-8 text-green-500" />
</div>
<h1 className="text-2xl font-bold text-slate-800">{t("workflows.thank_you_title")}</h1>
<p className="text-base text-slate-500">{t("workflows.thank_you_description")}</p>
</div>
</div>
);
};

View File

@@ -1,42 +0,0 @@
import { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getCloudBillingDisplayContext } from "@/modules/ee/billing/lib/cloud-billing-display";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { WorkflowsPage } from "./components/workflows-page";
export const metadata: Metadata = {
title: "Workflows",
};
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
if (!IS_FORMBRICKS_CLOUD) {
return notFound();
}
const { session, organization, isBilling } = await getEnvironmentAuth(params.environmentId);
if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
}
const user = await getUser(session.user.id);
if (!user) {
return redirect("/auth/login");
}
const cloudBillingDisplayContext = await getCloudBillingDisplayContext(organization.id);
return (
<WorkflowsPage
userEmail={user.email}
organizationName={organization.name}
billingPlan={cloudBillingDisplayContext.currentCloudPlan}
/>
);
};
export default Page;

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