Compare commits

..

5 Commits

Author SHA1 Message Date
pandeymangg
4327319d75 fix: placeholder 2024-07-03 12:33:05 +05:30
pandeymangg
dc4f146983 fixed the cal.com host not configured 2024-07-03 12:18:24 +05:30
pandeymangg
0ed6401df7 Merge remote-tracking branch 'origin/main' into aschaber-cal 2024-07-03 11:11:19 +05:30
Matti Nannt
e856006e04 Merge branch 'main' into feature/custom-calcom-host 2024-06-25 15:34:10 +02:00
Alexander Schaber
01210dba3f feat(calcom): add custom cal.com field
Closes: #2654
2024-06-23 16:50:14 +02:00
454 changed files with 6036 additions and 7188 deletions

View File

@@ -8,9 +8,6 @@
WEBAPP_URL=http://localhost:3000
# Required for next-auth. Should be the same as WEBAPP_URL
NEXTAUTH_URL=http://localhost:3000
# Set this if you want to have a shorter link for surveys
SHORT_URL_BASE=
@@ -34,9 +31,13 @@ DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=pu
# You can use: `openssl rand -hex 32` to generate a secure one
NEXTAUTH_SECRET=RANDOM_STRING
# Cron Secret (mandatory)
# Set this to your public-facing URL, e.g., https://example.com
# You do not need the NEXTAUTH_URL environment variable in Vercel.
NEXTAUTH_URL=http://localhost:3000
# Cron Secret
# You can use: `openssl rand -hex 32` to generate a secure one
CRON_SECRET=RANDOM_STRING
CRON_SECRET=
################
# MAIL SETUP #
@@ -53,9 +54,6 @@ SMTP_SECURE_ENABLED=0
SMTP_USER=smtpUser
SMTP_PASSWORD=smtpPassword
# If set to 0, the server will accept connections without requiring authorization from the list of supplied CAs (default is 1).
# SMTP_REJECT_UNAUTHORIZED_TLS=0
########################################################################
# ------------------------------ OPTIONAL -----------------------------#
########################################################################

View File

@@ -30,10 +30,6 @@ runs:
**/dist/**
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}
- name: Set Cache Hit Status
run: echo "cache-hit=${{ steps.cache-build.outputs.cache-hit }}" >> "$GITHUB_OUTPUT"
shell: bash
- name: Setup Node.js 20.x
uses: actions/setup-node@v3
with:
@@ -41,7 +37,7 @@ runs:
if: steps.cache-build.outputs.cache-hit != 'true'
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v2
if: steps.cache-build.outputs.cache-hit != 'true'
- name: Install dependencies

View File

@@ -11,8 +11,24 @@ jobs:
- uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
- name: Build & Cache Web Binaries
uses: ./.github/actions/cache-build-web
id: cache-build-web
- name: Setup Node.js 20.x
uses: actions/setup-node@v3
with:
e2e_testing_mode: "0"
node-version: 20.x
- name: Install pnpm
uses: pnpm/action-setup@v2
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
- name: Generate Random ENCRYPTION_KEY
run: |
SECRET=$(openssl rand -hex 32)
echo "ENCRYPTION_KEY=$SECRET" >> $GITHUB_ENV
- name: Build Formbricks-web
run: pnpm build --filter=@formbricks/web...

View File

@@ -43,7 +43,7 @@ jobs:
- name: Install pnpm
if: env.PNPM_INSTALLED == 'false'
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v2
- name: Install dependencies
if: env.PNPM_INSTALLED == 'false'

130
.github/workflows/kamal-deploy.yml vendored Normal file
View File

@@ -0,0 +1,130 @@
name: Kamal Deploy
concurrency:
group: deploy-to-kamal
cancel-in-progress: false
on:
workflow_dispatch:
#push:
# branches:
# - main
jobs:
Deploy:
runs-on: ubuntu-latest
environment: production
env:
DOCKER_BUILDKIT: 1
IS_FORMBRICKS_CLOUD: ${{ vars.IS_FORMBRICKS_CLOUD }}
WEBAPP_URL: ${{ vars.WEBAPP_URL }}
MIGRATE_DATABASE_URL: ${{ secrets.MIGRATE_DATABASE_URL }}
NEXTAUTH_URL: ${{ vars.NEXTAUTH_URL }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
SHORT_URL_BASE: ${{ vars.SHORT_URL_BASE }}
MAIL_FROM: ${{ secrets.MAIL_FROM }}
SMTP_HOST: ${{ secrets.SMTP_HOST }}
SMTP_PORT: ${{ secrets.SMTP_PORT }}
SMTP_USER: ${{ secrets.SMTP_USER }}
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
PRIVACY_URL: ${{ vars.PRIVACY_URL }}
TERMS_URL: ${{ vars.TERMS_URL }}
IMPRINT_URL: ${{ vars.IMPRINT_URL }}
GITHUB_ID: ${{ secrets.FB_GITHUB_ID }}
GITHUB_SECRET: ${{ secrets.FB_GITHUB_SECRET }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
AZUREAD_CLIENT_ID: ${{ secrets.AZUREAD_CLIENT_ID }}
AZUREAD_CLIENT_SECRET: ${{ secrets.AZUREAD_CLIENT_SECRET }}
AZUREAD_TENANT_ID: ${{ secrets.AZUREAD_TENANT_ID }}
OIDC_CLIENT_ID: ${{ secrets.OIDC_CLIENT_ID }}
OIDC_CLIENT_SECRET: ${{ secrets.OIDC_CLIENT_SECRET }}
OIDC_ISSUER: ${{ secrets.OIDC_ISSUER }}
OIDC_DISPLAY_NAME: ${{ secrets.OIDC_DISPLAY_NAME }}
OIDC_SIGNING_ALGORITHM: ${{ secrets.OIDC_SIGNING_ALGORITHM }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}
ASSET_PREFIX_URL: ${{ vars.ASSET_PREFIX_URL }}
NOTION_OAUTH_CLIENT_ID: ${{ secrets.NOTION_OAUTH_CLIENT_ID }}
NOTION_OAUTH_CLIENT_SECRET: ${{ secrets.NOTION_OAUTH_CLIENT_SECRET }}
SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }}
SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
GOOGLE_SHEETS_CLIENT_ID: ${{ secrets.GOOGLE_SHEETS_CLIENT_ID }}
GOOGLE_SHEETS_CLIENT_SECRET: ${{ secrets.GOOGLE_SHEETS_CLIENT_SECRET }}
GOOGLE_SHEETS_REDIRECT_URL: ${{ secrets.GOOGLE_SHEETS_REDIRECT_URL }}
AIRTABLE_CLIENT_ID: ${{ secrets.AIRTABLE_CLIENT_ID }}
ENTERPRISE_LICENSE_KEY: ${{ secrets.ENTERPRISE_LICENSE_KEY }}
DEFAULT_ORGANIZATION_ID: ${{ vars.DEFAULT_ORGANIZATION_ID }}
CUSTOMER_IO_API_KEY: ${{ secrets.CUSTOMER_IO_API_KEY }}
CUSTOMER_IO_SITE_ID: ${{ secrets.CUSTOMER_IO_SITE_ID }}
NEXT_PUBLIC_POSTHOG_API_KEY: ${{ vars.NEXT_PUBLIC_POSTHOG_API_KEY }}
NEXT_PUBLIC_POSTHOG_API_HOST: ${{ vars.NEXT_PUBLIC_POSTHOG_API_HOST }}
NEXT_PUBLIC_FORMBRICKS_API_HOST: ${{ vars.NEXT_PUBLIC_FORMBRICKS_API_HOST }}
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: ${{ vars.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID }}
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID: ${{ vars.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID }}
NEXT_PUBLIC_SENTRY_DSN: ${{ vars.NEXT_PUBLIC_SENTRY_DSN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
NODE_ENV: production
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
CLOUDFLARE_DNS_API_TOKEN: ${{ secrets.CLOUDFLARE_DNS_API_TOKEN }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
S3_REGION: ${{ vars.S3_REGION }}
S3_BUCKET_NAME: ${{ vars.S3_BUCKET_NAME }}
OPENTELEMETRY_LISTENER_URL: ${{ vars.OPENTELEMETRY_LISTENER_URL }}
RATE_LIMITING_DISABLED: ${{ vars.RATE_LIMITING_DISABLED }}
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
DB_HOST: ${{ secrets.DB_HOST }}
DB_USER: ${{ secrets.DB_USER }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
DB_NAME: ${{ secrets.DB_NAME }}
REDIS_URL: ${{ secrets.REDIS_URL }}
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.3.0
bundler-cache: true
- name: Install dependencies
run: |
gem install kamal
- uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
- name: Create builder
run: docker buildx create --use --name formbricks-gh-actions-builder
if: steps.buildx.outputs.should_create_builder == 'true'
- name: Push env variables to Kamal
run: |
kamal() { command kamal "$@" -c kamal/deploy.yml; }
kamal env push
- name: Run deploy command
run: |
kamal() { command kamal "$@" -c kamal/deploy.yml; }
set +e
DEPLOY_OUTPUT=$(kamal deploy 2>&1)
DEPLOY_EXIT_CODE=$?
echo "$DEPLOY_OUTPUT"
if [[ "$DEPLOY_OUTPUT" == *"container not unhealthy (healthy)"* ]]; then
echo "Deployment reported healthy container. Considering as success."
kamal lock release
exit 0
else
exit $DEPLOY_EXIT_CODE
fi
shell: bash

127
.github/workflows/kamal-setup.yml vendored Normal file
View File

@@ -0,0 +1,127 @@
name: Kamal Setup
concurrency:
group: setup-kamal
cancel-in-progress: false
on:
workflow_dispatch: # Only to be triggered when accessories are updated
jobs:
Setup:
runs-on: ubuntu-latest
environment: production
env:
DOCKER_BUILDKIT: 1
IS_FORMBRICKS_CLOUD: ${{ vars.IS_FORMBRICKS_CLOUD }}
WEBAPP_URL: ${{ vars.WEBAPP_URL }}
NEXTAUTH_URL: ${{ vars.NEXTAUTH_URL }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
MIGRATE_DATABASE_URL: ${{ secrets.MIGRATE_DATABASE_URL }}
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
SHORT_URL_BASE: ${{ vars.SHORT_URL_BASE }}
MAIL_FROM: ${{ secrets.MAIL_FROM }}
SMTP_HOST: ${{ secrets.SMTP_HOST }}
SMTP_PORT: ${{ secrets.SMTP_PORT }}
SMTP_USER: ${{ secrets.SMTP_USER }}
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
PRIVACY_URL: ${{ vars.PRIVACY_URL }}
TERMS_URL: ${{ vars.TERMS_URL }}
IMPRINT_URL: ${{ vars.IMPRINT_URL }}
GITHUB_ID: ${{ secrets.FB_GITHUB_ID }}
GITHUB_SECRET: ${{ secrets.FB_GITHUB_SECRET }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
AZUREAD_CLIENT_ID: ${{ secrets.AZUREAD_CLIENT_ID }}
AZUREAD_CLIENT_SECRET: ${{ secrets.AZUREAD_CLIENT_SECRET }}
AZUREAD_TENANT_ID: ${{ secrets.AZUREAD_TENANT_ID }}
OIDC_CLIENT_ID: ${{ secrets.OIDC_CLIENT_ID }}
OIDC_CLIENT_SECRET: ${{ secrets.OIDC_CLIENT_SECRET }}
OIDC_ISSUER: ${{ secrets.OIDC_ISSUER }}
OIDC_DISPLAY_NAME: ${{ secrets.OIDC_DISPLAY_NAME }}
OIDC_SIGNING_ALGORITHM: ${{ secrets.OIDC_SIGNING_ALGORITHM }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}
ASSET_PREFIX_URL: ${{ vars.ASSET_PREFIX_URL }}
NOTION_OAUTH_CLIENT_ID: ${{ secrets.NOTION_OAUTH_CLIENT_ID }}
NOTION_OAUTH_CLIENT_SECRET: ${{ secrets.NOTION_OAUTH_CLIENT_SECRET }}
SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }}
SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
GOOGLE_SHEETS_CLIENT_ID: ${{ secrets.GOOGLE_SHEETS_CLIENT_ID }}
GOOGLE_SHEETS_CLIENT_SECRET: ${{ secrets.GOOGLE_SHEETS_CLIENT_SECRET }}
GOOGLE_SHEETS_REDIRECT_URL: ${{ secrets.GOOGLE_SHEETS_REDIRECT_URL }}
AIRTABLE_CLIENT_ID: ${{ secrets.AIRTABLE_CLIENT_ID }}
ENTERPRISE_LICENSE_KEY: ${{ secrets.ENTERPRISE_LICENSE_KEY }}
DEFAULT_ORGANIZATION_ID: ${{ vars.DEFAULT_ORGANIZATION_ID }}
CUSTOMER_IO_API_KEY: ${{ secrets.CUSTOMER_IO_API_KEY }}
CUSTOMER_IO_SITE_ID: ${{ secrets.CUSTOMER_IO_SITE_ID }}
NEXT_PUBLIC_POSTHOG_API_KEY: ${{ vars.NEXT_PUBLIC_POSTHOG_API_KEY }}
NEXT_PUBLIC_POSTHOG_API_HOST: ${{ vars.NEXT_PUBLIC_POSTHOG_API_HOST }}
NEXT_PUBLIC_FORMBRICKS_API_HOST: ${{ vars.NEXT_PUBLIC_FORMBRICKS_API_HOST }}
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: ${{ vars.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID }}
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID: ${{ vars.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID }}
NEXT_PUBLIC_SENTRY_DSN: ${{ vars.NEXT_PUBLIC_SENTRY_DSN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
NODE_ENV: production
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
CLOUDFLARE_DNS_API_TOKEN: ${{ secrets.CLOUDFLARE_DNS_API_TOKEN }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
S3_REGION: ${{ vars.S3_REGION }}
S3_BUCKET_NAME: ${{ vars.S3_BUCKET_NAME }}
OPENTELEMETRY_LISTENER_URL: ${{ vars.OPENTELEMETRY_LISTENER_URL }}
RATE_LIMITING_DISABLED: ${{ vars.RATE_LIMITING_DISABLED }}
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
DB_HOST: ${{ secrets.DB_HOST }}
DB_USER: ${{ secrets.DB_USER }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
DB_NAME: ${{ secrets.DB_NAME }}
REDIS_URL: ${{ secrets.REDIS_URL }}
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.3.0
bundler-cache: true
- name: Install dependencies
run: |
gem install kamal
- uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
- name: Create builder
run: docker buildx create --use --name formbricks-gh-actions-builder
if: steps.buildx.outputs.should_create_builder == 'true'
- name: Push env variables to Kamal
run: |
kamal() { command kamal "$@" -c kamal/deploy.yml; }
kamal env push
- name: Run setup command
run: |
kamal() { command kamal "$@" -c kamal/deploy.yml; }
set +e
DEPLOY_OUTPUT=$(kamal setup 2>&1)
DEPLOY_EXIT_CODE=$?
echo "$DEPLOY_OUTPUT"
if [[ "$DEPLOY_OUTPUT" == *"container not unhealthy (healthy)"* ]]; then
echo "Deployment reported healthy container. Considering as success."
kamal lock release
exit 0
else
exit $DEPLOY_EXIT_CODE
fi
shell: bash

View File

@@ -11,13 +11,13 @@ jobs:
- uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
- name: Setup Node.js 18.x
uses: actions/setup-node@v3
with:
node-version: 20.x
node-version: 18.x
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v2
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64

View File

@@ -17,7 +17,7 @@ jobs:
node-version: 20.x
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v2
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64

View File

@@ -33,6 +33,7 @@ tasks:
gp sync-await init &&
cp .env.example .env &&
sed -i -r "s#^(WEBAPP_URL=).*#\1 $(gp url 3000)#" .env &&
sed -i -r "s#^(NEXTAUTH_URL=).*#\1 $(gp url 3000)#" .env &&
RANDOM_ENCRYPTION_KEY=$(openssl rand -hex 32)
sed -i 's/^ENCRYPTION_KEY=.*/ENCRYPTION_KEY='"$RANDOM_ENCRYPTION_KEY"'/' .env
turbo --filter "@formbricks/web" go

14
.kamal/hooks/post-deploy.sample Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# A sample post-deploy hook
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_ROLE (if set)
# KAMAL_DESTINATION (if set)
# KAMAL_RUNTIME
echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds"

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "Rebooted Traefik on $KAMAL_HOSTS"

51
.kamal/hooks/pre-build.sample Executable file
View File

@@ -0,0 +1,51 @@
#!/bin/sh
# A sample pre-build hook
#
# Checks:
# 1. We have a clean checkout
# 2. A remote is configured
# 3. The branch has been pushed to the remote
# 4. The version we are deploying matches the remote
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_ROLE (if set)
# KAMAL_DESTINATION (if set)
if [ -n "$(git status --porcelain)" ]; then
echo "Git checkout is not clean, aborting..." >&2
git status --porcelain >&2
exit 1
fi
first_remote=$(git remote)
if [ -z "$first_remote" ]; then
echo "No git remote set, aborting..." >&2
exit 1
fi
current_branch=$(git branch --show-current)
if [ -z "$current_branch" ]; then
echo "Not on a git branch, aborting..." >&2
exit 1
fi
remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1)
if [ -z "$remote_head" ]; then
echo "Branch not pushed to remote, aborting..." >&2
exit 1
fi
if [ "$KAMAL_VERSION" != "$remote_head" ]; then
echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2
exit 1
fi
exit 0

47
.kamal/hooks/pre-connect.sample Executable file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env ruby
# A sample pre-connect check
#
# Warms DNS before connecting to hosts in parallel
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_ROLE (if set)
# KAMAL_DESTINATION (if set)
# KAMAL_RUNTIME
hosts = ENV["KAMAL_HOSTS"].split(",")
results = nil
max = 3
elapsed = Benchmark.realtime do
results = hosts.map do |host|
Thread.new do
tries = 1
begin
Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME)
rescue SocketError
if tries < max
puts "Retrying DNS warmup: #{host}"
tries += 1
sleep rand
retry
else
puts "DNS warmup failed: #{host}"
host
end
end
tries
end
end.map(&:value)
end
retries = results.sum - hosts.size
nopes = results.count { |r| r == max }
puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ]

109
.kamal/hooks/pre-deploy.sample Executable file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env ruby
# A sample pre-deploy hook
#
# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds.
#
# Fails unless the combined status is "success"
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_COMMAND
# KAMAL_SUBCOMMAND
# KAMAL_ROLE (if set)
# KAMAL_DESTINATION (if set)
# Only check the build status for production deployments
if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production"
exit 0
end
require "bundler/inline"
# true = install gems so this is fast on repeat invocations
gemfile(true, quiet: true) do
source "https://rubygems.org"
gem "octokit"
gem "faraday-retry"
end
MAX_ATTEMPTS = 72
ATTEMPTS_GAP = 10
def exit_with_error(message)
$stderr.puts message
exit 1
end
class GithubStatusChecks
attr_reader :remote_url, :git_sha, :github_client, :combined_status
def initialize
@remote_url = `git config --get remote.origin.url`.strip.delete_prefix("https://github.com/")
@git_sha = `git rev-parse HEAD`.strip
@github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"])
refresh!
end
def refresh!
@combined_status = github_client.combined_status(remote_url, git_sha)
end
def state
combined_status[:state]
end
def first_status_url
first_status = combined_status[:statuses].find { |status| status[:state] == state }
first_status && first_status[:target_url]
end
def complete_count
combined_status[:statuses].count { |status| status[:state] != "pending"}
end
def total_count
combined_status[:statuses].count
end
def current_status
if total_count > 0
"Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..."
else
"Build not started..."
end
end
end
$stdout.sync = true
puts "Checking build status..."
attempts = 0
checks = GithubStatusChecks.new
begin
loop do
case checks.state
when "success"
puts "Checks passed, see #{checks.first_status_url}"
exit 0
when "failure"
exit_with_error "Checks failed, see #{checks.first_status_url}"
when "pending"
attempts += 1
end
exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS
puts checks.current_status
sleep(ATTEMPTS_GAP)
checks.refresh!
end
rescue Octokit::NotFound
exit_with_error "Build status could not be found"
end

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "Rebooting Traefik on $KAMAL_HOSTS..."

View File

@@ -160,7 +160,7 @@ Here is what you need to be able to run Formbricks:
### Local Setup
To get started locally, we've got a [guide to help you](https://formbricks.com/docs/developer-docs/contributing/get-started#local-machine-setup).
To get started locally, we've got a [guide to help you](https://formbricks.com/docs/contributing/setup).
### Gitpod Setup
@@ -184,7 +184,7 @@ Here are a few options:
- Upvote issues with 👍 reaction so we know what the demand for a particular issue is to prioritize it within the roadmap.
Please check out [our contribution guide](https://formbricks.com/docs/developer-docs/contributing/get-started) and our [list of open issues](https://github.com/formbricks/formbricks/issues) for more information.
Please check out [our contribution guide](https://formbricks.com/docs/contributing/introduction) and our [list of open issues](https://github.com/formbricks/formbricks/issues) for more information.
## All Thanks To Our Contributors

View File

@@ -17,17 +17,11 @@ export const metadata = {
# Advanced Targeting
<Note>
Targeting based on actions is deprecated in Advanced Targeting and will be removed soon. We recommend using
filters on user attributes to target the survey only to specific groups of users.
</Note>
Advanced Targeting allows you to show surveys to the right group of people. You can target surveys based on user attributes, user events, and more instead of spraying and praying. This helps you get more relevant feedback and make data-driven decisions. All of this without writing a single line of code.
Advanced Targeting allows you to show surveys to the right group of people. You can target surveys based on user attributes, device type, and more instead of spraying and praying. This helps you get more relevant feedback and make data-driven decisions. All of this without writing a single line of code.
<ResponsiveVideo title="Formbricks Multi-language Surveys"
src="https://www.youtube-nocookie.com/embed/0BQp6N4cXzU?si=KeBM7G7Ch1xtrsOm&amp;controls=0" />
<ResponsiveVideo
title="Formbricks Multi-language Surveys"
src="https://www.youtube-nocookie.com/embed/0BQp6N4cXzU?si=KeBM7G7Ch1xtrsOm&amp;controls=0"
/>
## How to setup Advanced Targeting

View File

@@ -69,18 +69,18 @@ Refer to our [Example HTML project](https://github.com/formbricks/examples/tree/
## ReactJS
Install the Formbricks SDK using one of the package managers ie `npm`,`pnpm`,`yarn`. Note that zod is required as a peer dependency must also be installed in your project.
Install the Formbricks SDK using one of the package managers ie `npm`,`pnpm`,`yarn`.
<Col>
<CodeGroup title="Install Formbricks JS library">
```shell {{ title: 'npm' }}
npm install @formbricks/js zod
npm install @formbricks/js
```
```shell {{ title: 'pnpm' }}
pnpm add @formbricks/js zod
pnpm add @formbricks/js
```
```shell {{ title: 'yarn' }}
yarn add @formbricks/js zod
yarn add @formbricks/js
```
</CodeGroup>
@@ -142,13 +142,13 @@ Code snippets for the integration for both conventions are provided to further a
<Col>
<CodeGroup title="Install Formbricks JS library">
```shell {{ title: 'npm' }}
npm install @formbricks/js zod
npm install @formbricks/js
```
```shell {{ title: 'pnpm' }}
pnpm add @formbricks/js zod
pnpm add @formbricks/js
```
```shell {{ title: 'yarn' }}
yarn add @formbricks/js zod
yarn add @formbricks/js
```
</CodeGroup>
@@ -164,6 +164,7 @@ yarn add @formbricks/js zod
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import formbricks from "@formbricks/js/app";
export default function FormbricksProvider() {
@@ -217,6 +218,7 @@ Refer to our [Example NextJS App Directory project](https://github.com/formbrick
// other import
import { useRouter } from "next/router";
import { useEffect } from "react";
import formbricks from "@formbricks/js/app";
if (typeof window !== "undefined") {

View File

@@ -21,7 +21,6 @@ We are so happy that you are interested in contributing to Formbricks 🤗 There
- **How to create a service**: [Read this document to understand how we use services](https://formbricks.notion.site/How-to-create-a-service-8e0c035704bb40cb9ea5e5beeeeabd67?pvs=4). This is particulalry important when you need to write a new one.
## Talk to us first
We highly recommend connecting with us on [Discord server](https://formbricks.com/discord) before you ship a contribution. This will increase the likelihood of your PR being merged. And it will decrease the likelihood of you wasting your time :)
## Contributor License Agreement (CLA)
@@ -91,29 +90,14 @@ cp .env.example .env
</CodeGroup>
</Col>
4. Generate & set some secret values mandatory for the `ENCRYPTION_KEY`, `NEXTAUTH_SECRET` and `CRON_SECRET` in the .env file. You can use the following command to generate the random string of required length:
4. Generate & set some secret values mandatory for the `ENCRYPTION_KEY` & `NEXTAUTH_SECRET` in the .env file. You can use the following command to generate the random string of required length:
- For Linux
<Col>
<CodeGroup title="For Linux">
<CodeGroup title="Set value of ENCRYPTION_KEY">
```bash
sed -i '/^ENCRYPTION_KEY=/c\ENCRYPTION_KEY='$(openssl rand -hex 32) .env
sed -i '/^NEXTAUTH_SECRET=/c\NEXTAUTH_SECRET='$(openssl rand -hex 32) .env
sed -i '/^CRON_SECRET=/c\CRON_SECRET='$(openssl rand -hex 32) .env
```
</CodeGroup>
</Col>
- For Mac
<Col>
<CodeGroup title="For Mac">
```bash
sed -i '' '/^ENCRYPTION_KEY=/s|.*|ENCRYPTION_KEY='$(openssl rand -hex 32)'|' .env
sed -i '' '/^NEXTAUTH_SECRET=/s|.*|NEXTAUTH_SECRET='$(openssl rand -hex 32)'|' .env
sed -i '' '/^CRON_SECRET=/s|.*|CRON_SECRET='$(openssl rand -hex 32)'|' .env
```
</CodeGroup>
@@ -165,4 +149,4 @@ pnpm build
```
</CodeGroup>
</Col>
</Col>

View File

@@ -35,7 +35,7 @@ export const metadata = {
3. To prevent the "Init" task from running indefinitely due to prebuild rules, a cleanup `docker compose down` step i.e. `db:down` is added to `turbo.json`. This step is designed to halt the execution of containers that are currently running.
- When the workspace starts:
1. Initializing environment variables.
2. Replacing `NEXT_PUBLIC_WEBAPP_URL` to take in Gitpod URL's ports when running on VSCode browser.
2. Replacing `NEXT_PUBLIC_WEBAPP_URL` and `NEXTAUTH_URL` to take in Gitpod URL's ports when running on VSCode browser.
3. Starting the `@formbricks/web` dev environment.
**Demo Component Initialization:**
@@ -81,8 +81,8 @@ session.
of your workspace. - You can use either choose either VS Code Browser or VS Code Desktop editor with the
'Standard Class' for your workspace class. - If you opt for the VS Code Desktop, follow the following steps 1.
Gitpod will prompt you to grant access to the VSCode app. Once approved, install the GitPod extension from the
VSCode Marketplace and follow the prompts to authorize the integration. 2. Change the `WEBAPP_URL` to
`https://localhost:3000`
VSCode Marketplace and follow the prompts to authorize the integration. 2. Change the `WEBAPP_URL` and the
`NEXTAUTH_URL` to `https://localhost:3000`
### 4. Gitpod preparing the created Workspace
@@ -169,4 +169,4 @@ Here are the ports and corresponding URLs for the services within your Gitpod en
className="max-w-full rounded-lg sm:max-w-3xl"
/>
These URLs and port numbers represent various services and endpoints within your Gitpod environment. You can access and interact with these services by the Port URL for the respective service.
These URLs and port numbers represent various services and endpoints within your Gitpod environment. You can access and interact with these services by the Port URL for the respective service.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -4,7 +4,7 @@ import AddModule from "./add-module.webp";
import CreateNewScenario from "./create-new-scenario.webp";
import CreateWebhook from "./create-webhook.webp";
import DuplicateSurvey from "./duplicate-survey.webp";
import EnterApiKeyAndHost from "./enter-api-key-and-host.webp";
import EnterApiKey from "./enter-api-key.webp";
import Result from "./result.webp";
import SearchFormbricks from "./search-formbricks.webp";
import SelectAction from "./select-action.webp";
@@ -27,8 +27,8 @@ export const metadata = {
Make is a powerful tool to send information between Formbricks and thousands of apps. Here's how to set it up.
<Note>
Nailed down your survey?? Any changes in the survey cause additional work in the _Scenario_. It makes sense
to first settle on the survey you want to run and then get to setting up Make.
Nailed down your survey?? Any changes in the survey cause additional work in the _Scenario_. It
makes sense to first settle on the survey you want to run and then get to setting up Make.
</Note>
## Step 1: Setup your survey incl. `questionId` for every question
@@ -95,10 +95,10 @@ Click "Create a webhook":
className="max-w-full rounded-lg sm:max-w-3xl"
/>
Enter the Formbricks API Host and API Key. API Host is by default set to https://app.formbricks.com but can be modified for self hosting instances. Learn how to get an API Key from the [API Key tutorial](/additional-features/api#how-to-generate-an-api-key).
Enter the Formbricks API key. Learn how to get one from the [API Key tutorial](/additional-features/api#how-to-generate-an-api-key).
<MdxImage
src={EnterApiKeyAndHost}
src={EnterApiKey}
alt="Enter API Key"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"

View File

@@ -112,7 +112,7 @@ Enabling the Slack Integration in a self-hosted environment requires a setup usi
"go": next dev --experimental-https -p 3000
```
- You also need to update the .env file in the `apps/web` directory to include the `WEBAPP_URL` as `https://localhost:3000` instead of `http://localhost:3000`.
- You also need to update the .env file in the `apps/web` directory to include the `NEXTAUTH_URL` and `WEBAPP_URL` as `https://localhost:3000` instead of `http://localhost:3000`.
- You also need to run the terminal in admin mode to run the `go` script(to acquire the SSL certificate). You can do this by running the terminal as an administrator or using the `sudo` command in Unix-based systems.

View File

@@ -40,7 +40,8 @@ For more information on user roles & permissions, see below:
| Update Member Access | ✅ | ✅ | ❌ | ❌ | ❌ |
| Update Billing | ✅ | ✅ | ❌ | ❌ | ❌ |
| **Product** | | | | | |
| Create Product | ✅ | ✅ | | | ❌ |
| Create Product | ✅ | ✅ | | | ❌ |
| Update Product Name | ✅ | ✅ | ✅ | ❌ | ❌ |
| Update Product Name | ✅ | ✅ | ✅ | ❌ | ❌ |
| Update Product Recontact Options | ✅ | ✅ | ✅ | ✅ | ❌ |
| Update Look & Feel | ✅ | ✅ | ✅ | ✅ | ❌ |
@@ -84,7 +85,7 @@ There are two ways to invite organization members: One by one or in bulk.
src={MenuItem}
alt="Where to find the Menu Item for Organization Settings"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
className="max-w-full rounded-lg sm:max-w-3xl "
/>
2. Click on the `Add Member` button:
@@ -93,7 +94,7 @@ There are two ways to invite organization members: One by one or in bulk.
src={AddMember}
alt="Add Member Button Position"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
className="max-w-full rounded-lg sm:max-w-3xl "
/>
3. In the modal, add the Name, Email and Role of the organization member you want to invite:
@@ -102,7 +103,7 @@ There are two ways to invite organization members: One by one or in bulk.
src={IndvInvite}
alt="Individual Invite Modal Tab"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
className="max-w-full rounded-lg sm:max-w-3xl "
/>
<Note>
@@ -120,7 +121,7 @@ Formbricks sends an email to the organization member with an invitation link. Th
src={MenuItem}
alt="Where to find the Menu Item for Organization Settings"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
className="max-w-full rounded-lg sm:max-w-3xl "
/>
2. Click on the `Add Member` button:
@@ -129,7 +130,7 @@ Formbricks sends an email to the organization member with an invitation link. Th
src={AddMember}
alt="Add Member Button Position"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
className="max-w-full rounded-lg sm:max-w-3xl "
/>
3. In the modal, switch to `Bulk Invite`. You can download an example .CSV file to fill in the Name, Email and Role of the organization members you want to invite:
@@ -138,7 +139,7 @@ Formbricks sends an email to the organization member with an invitation link. Th
src={BulkInvite}
alt="Individual Invite Modal Tab"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
className="max-w-full rounded-lg sm:max-w-3xl "
/>
4. Upload the filled .CSV file and invite the organization members in bulk ✅

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -3,17 +3,13 @@ import { TellaVideo } from "@/components/TellaVideo";
import EmailContentWithSurvey from "./images/email-content-with-survey.webp";
import EmailContentWithoutSurvey from "./images/email-content-without-survey.webp";
import EmbedModeDisabled from "./images/embed-mode-disabled.webp";
import EmbedModeEnabled from "./images/embed-mode-enabled.webp";
import EmbedModeToggle from "./images/embed-mode-toggle.webp";
import JoSignature from "./images/jo-signature.webp";
import PluginAddSurvey from "./images/plugin-add-survey.webp";
import PluginSourceTab from "./images/plugin-source-tab.webp";
export const metadata = {
title: "Embed Surveys in Your Web Page & Email",
description:
"Embed Formbricks surveys seamlessly into your website using an iframe & Email using code snippets.",
description: "Embed Formbricks surveys seamlessly into your website using an iframe & Email using code snippets.",
};
#### Embed Surveys
@@ -91,43 +87,6 @@ window.addEventListener("message", (event) => {
</CodeGroup>
</Col>
## Emebd Mode
Embed your survey with a minimalist design, disregarding padding and background.
### How to enable it?
It can be enabled by simply appending **?embed=true** to your survey link or from UI
1. Open Embed survey tab in survey share modal
2. Toggle **Embed mode**
<MdxImage
src={EmbedModeToggle}
alt="Toggle embed mode"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
### With Embed mode enabled
<MdxImage
src={EmbedModeEnabled}
alt="Toggle embed mode"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
### With Embed mode disabled
<MdxImage
src={EmbedModeDisabled}
alt="Toggle embed mode"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
## Embedding Surveys in Emails
Embedding Formbricks surveys directly into your emails allows you to collect valuable feedback from your users at every touchpoint. Seamlessly integrate interactive surveys into your email campaigns to gather insights and improve user experience.
@@ -168,7 +127,7 @@ Gmail does not support HTML embedding natively. It's a WYSIWYG (What You See Is
src={EmailContentWithoutSurvey}
alt="Choose a link survey template"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
className="max-w-full rounded-lg sm:max-w-3xl "
/>
- Right next to the Send button you will see a new button called **HTML Editor**. Click on it.
@@ -180,7 +139,7 @@ Gmail does not support HTML embedding natively. It's a WYSIWYG (What You See Is
src={PluginSourceTab}
alt="Choose a link survey template"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
className="max-w-full rounded-lg sm:max-w-3xl "
/>
- Now paste the copied HTML code from Formbricks into this window. On the right, you will see a preview of how the email will look.
@@ -191,7 +150,7 @@ Gmail does not support HTML embedding natively. It's a WYSIWYG (What You See Is
src={PluginAddSurvey}
alt="Choose a link survey template"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
className="max-w-full rounded-lg sm:max-w-3xl "
/>
- Click on the **Close Editor** button to save the changes & close the editor.
@@ -202,7 +161,7 @@ Gmail does not support HTML embedding natively. It's a WYSIWYG (What You See Is
src={EmailContentWithSurvey}
alt="Choose a link survey template"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
className="max-w-full rounded-lg sm:max-w-3xl "
/>
- Voila! You have successfully embedded the survey in your email.
@@ -240,7 +199,7 @@ Embed a survey link in your email signature to collect feedback subtly yet effec
src={JoSignature}
alt="Choose a link survey template"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
className="max-w-full rounded-lg sm:max-w-3xl "
/>
1. Create a Survey: Adjust an existing survey or create a new one.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,131 +0,0 @@
import { MdxImage } from "@/components/MdxImage";
import CopySurveyLink from "./copy-survey-link.webp";
import CreateStudy from "./create-study.webp";
import HiddenFields from "./hidden-fields.webp";
import PreviewComplete from "./preview-complete.webp";
import PreviewStudy from "./preview-study.webp";
import AddRedirectUrl from "./redirect-url-formbricks.webp";
import RedirectUrl from "./redirect-url.webp";
import ScreeningOut from "./screening-out.webp";
import UrlParameters from "./url-parameters.webp";
export const metadata = {
title: "Creating a Research Panel with Prolific",
description:
"Formbricks surveys can be integrated with Prolifics participant panel easily. This tutorial walks you through the steps on how to access a pool of over 200.000 participants for your research.",
};
#### Research Panel
# Creating a Research Panel with Prolific
You need a lot of research participants that match your target audience fast?
Formbricks integrates well with Prolific. Prolific provides a pool of over 200.000 research participants you can choose from. Run market research with Formbricks within hours, not days.
<Note>
Prolific is a paid service. You need to fund your account to access the pool of participants. The cost depends on the number of participants you want to reach and the demographics you're targeting. You can get an estimate of the cost with the [Prolific price calculator](https://www.prolific.com/calculator)
</Note>
## Purpose
External research panels are useful when:
- You don't have access to enough people who match your target audience
- You want to reach a specific demographic
- You want to reach a large number of people quickly
## Steps to Follow
### Step 1: Add hidden fields to the Formbricks survey
To be able to attribute a completed answer to a research participant, you need to add hidden fields to your Formbricks survey. To do so, edit your survey and scroll down to the Hidden Fields card.
Add three fields with the IDs `PROLIFIC_PID`, `STUDY_ID`, and `SESSION_ID`.
<MdxImage
src={HiddenFields}
alt="Hidden fields added"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
### Step 2: Create an account on Prolific
Go to [Prolific](https://app.prolific.co/) and create an account.
### Step 3: Create a study on Prolific
Once you're logged in to Prolific, create a new study.
<MdxImage
src={CreateStudy}
alt="Create a study on Prolific"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
### Step 4: Copy the Formbricks survey link to the Prolific study
We connect the Formbricks survey with the Prolific study by copying the survey link from Formbricks and pasting it into the Prolific study:
<MdxImage
src={CopySurveyLink}
alt="Copy the survey link"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
### Step 5: Choose URL parameters for attribution
To attribute responses to the correct participant, you need to add URL parameters to the Formbricks survey link. The parameters are `PROLIFIC_PID`, `STUDY_ID`, and `SESSION_ID`, exactly like the hidden fields you added.
<MdxImage
src={UrlParameters}
alt="Adding URL parameters to the survey"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
### Step 6: Update the Formbricks Redirect URL
To ensure that participants are redirected back to Prolific after completing the survey, add the redirect URL provided in the Prolific study setup (e.g. `https://app.prolific.co/submissions/complete?cc=I2PWSFRG`)
Copy from Prolific:
<MdxImage
src={RedirectUrl}
alt="Copy redirect URL"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
Set it up as Redirect URL in the Response Options in Formbricks:
<MdxImage
src={AddRedirectUrl}
alt="Add redirect URL to Formbricks"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
### Step 7: Preview the study
Preview the study using Prolific's [Preview-functionality](https://researcher-help.prolific.com/hc/en-gb/articles/360009222853-Previewing-your-study)
<MdxImage
src={PreviewStudy}
alt="Preview study"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
Got to the success screen? Then you're ready to publish your study!
<MdxImage
src={PreviewComplete}
alt="Preview complete"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
### Step 8: Publish the study
After you've published the study, you'll get the first responses within a few hours.
<Note>
Prolific is a paid service. You need to fund your account to publish your study.
</Note>
### That's it! 🎉
Once you've published the survey, you can sit back and watch the responses come in. Prolific will take care of the rest.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -12,56 +12,55 @@ export const metadata = {
These variables are present inside your machines docker-compose file. Restart the docker containers if you change any variables for them to take effect.
| Variable | Description | Required | Default |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------- |
| WEBAPP_URL | Base URL of the site. | required | http://localhost:3000 |
| NEXTAUTH_URL | Location of the auth server. This should normally be the same as WEBAPP_URL | required | http://localhost:3000 |
| DATABASE_URL | Database URL with credentials. | required | |
| NEXTAUTH_SECRET | Secret for NextAuth, used for session signing and encryption. | required | (Generated by the user) |
| ENCRYPTION_KEY | Secret for used by Formbricks for data encryption | required | (Generated by the user) |
| CRON_SECRET | API Secret for running cron jobs. | required | |
| UPLOADS_DIR | Local directory for storing uploads. | optional | ./uploads |
| S3_ACCESS_KEY | Access key for S3. | optional | (resolved by the AWS SDK) |
| S3_SECRET_KEY | Secret key for S3. | optional | (resolved by the AWS SDK) |
| S3_REGION | Region for S3. | optional | (resolved by the AWS SDK) |
| S3_BUCKET_NAME | S3 bucket name for data storage. Formbricks enables S3 storage when this is set. | optional (required if S3 is enabled) | |
| S3_ENDPOINT | Endpoint for S3. | optional | (resolved by the AWS SDK) |
| PRIVACY_URL | URL for privacy policy. | optional | |
| TERMS_URL | URL for terms of service. | optional | |
| IMPRINT_URL | URL for imprint. | optional | |
| EMAIL_AUTH_DISABLED | Disables the ability for users to signup or login via email and password if set to 1. | optional | |
| PASSWORD_RESET_DISABLED | Disables password reset functionality if set to 1. | optional | |
| EMAIL_VERIFICATION_DISABLED | Disables email verification if set to 1. | optional | |
| RATE_LIMITING_DISABLED | Disables rate limiting if set to 1. | optional | |
| INVITE_DISABLED | Disables the ability for invited users to create an account if set to 1. | optional | |
| MAIL_FROM | Email address to send emails from. | optional (required if email services are to be enabled) | |
| SMTP_HOST | Host URL of your SMTP server. | optional (required if email services are to be enabled) | |
| SMTP_PORT | Host Port of your SMTP server. | optional (required if email services are to be enabled) | |
| SMTP_USER | Username for your SMTP Server. | optional (required if email services are to be enabled) | |
| SMTP_PASSWORD | Password for your SMTP Server. | optional (required if email services are to be enabled) | |
| SMTP_SECURE_ENABLED | SMTP secure connection. For using TLS, set to 1 else to 0. | optional (required if email services are to be enabled) | |
| SMTP_REJECT_UNAUTHORIZED_TLS | If set to 0, the server will accept connections without requiring authorization from the list of supplied CAs. | optional | 1 |
| GITHUB_ID | Client ID for GitHub. | optional (required if GitHub auth is enabled) | |
| GITHUB_SECRET | Secret for GitHub. | optional (required if GitHub auth is enabled) | |
| GOOGLE_CLIENT_ID | Client ID for Google. | optional (required if Google auth is enabled) | |
| GOOGLE_CLIENT_SECRET | Secret for Google. | optional (required if Google auth is enabled) | |
| STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | |
| STRIPE_WEBHOOK_SECRET | Webhook secret for Stripe integration. | optional | |
| TELEMETRY_DISABLED | Disables telemetry if set to 1. | optional | |
| INSTANCE_ID | Instance ID for Formbricks Cloud to be sent to Telemetry. | optional | |
| INTERNAL_SECRET | Internal Secret (Currently we overwrite the value with a random value). | optional | |
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
| DEFAULT_ORGANIZATION_ID | Automatically assign new users to a specific organization when joining | optional | |
| DEFAULT_ORGANIZATION_ROLE | Role of the user in the default organization. | optional | admin |
| OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | |
| OIDC_CLIENT_ID | Client ID for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
| OIDC_CLIENT_SECRET | Secret for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
| OIDC_ISSUER | Issuer URL for Custom OpenID Connect Provider (should have .well-known configured at this) | optional (required if OIDC auth is enabled) | |
| OIDC_SIGNING_ALGORITHM | Signing Algorithm for Custom OpenID Connect Provider | optional | RS256 |
| OPENTELEMETRY_LISTENER_URL | URL for OpenTelemetry listener inside Formbricks. | optional | |
| CUSTOM_CACHE_DISABLED | Disables custom cache handler if set to 1 (required for deployment on Vercel) | optional | |
| `<add more>` | | | |
| | | | |
| Variable | Description | Required | Default |
| --------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------- | ------------------------- |
| WEBAPP_URL | Base URL of the site. | required | http://localhost:3000 |
| DATABASE_URL | Database URL with credentials. | required | |
| NEXTAUTH_SECRET | Secret for NextAuth, used for session signing and encryption. | required | (Generated by the user) |
| ENCRYPTION_KEY | Secret for used by Formbricks for data encryption | required | (Generated by the user) |
| NEXTAUTH_URL | Location of the auth server. By default, this is the Formbricks docker instance itself. | required | http://localhost:3000 |
| UPLOADS_DIR | Local directory for storing uploads. | optional | ./uploads |
| S3_ACCESS_KEY | Access key for S3. | optional | (resolved by the AWS SDK) |
| S3_SECRET_KEY | Secret key for S3. | optional | (resolved by the AWS SDK) |
| S3_REGION | Region for S3. | optional | (resolved by the AWS SDK) |
| S3_BUCKET_NAME | S3 bucket name for data storage. Formbricks enables S3 storage when this is set. | optional (required if S3 is enabled) | |
| S3_ENDPOINT | Endpoint for S3. | optional | (resolved by the AWS SDK) |
| PRIVACY_URL | URL for privacy policy. | optional | |
| TERMS_URL | URL for terms of service. | optional | |
| IMPRINT_URL | URL for imprint. | optional | |
| EMAIL_AUTH_DISABLED | Disables the ability for users to signup or login via email and password if set to 1. | optional | |
| PASSWORD_RESET_DISABLED | Disables password reset functionality if set to 1. | optional | |
| EMAIL_VERIFICATION_DISABLED | Disables email verification if set to 1. | optional | |
| RATE_LIMITING_DISABLED | Disables rate limiting if set to 1. | optional | |
| INVITE_DISABLED | Disables the ability for invited users to create an account if set to 1. | optional | |
| MAIL_FROM | Email address to send emails from. | optional (required if email services are to be enabled) | |
| SMTP_HOST | Host URL of your SMTP server. | optional (required if email services are to be enabled) | |
| SMTP_PORT | Host Port of your SMTP server. | optional (required if email services are to be enabled) | |
| SMTP_USER | Username for your SMTP Server. | optional (required if email services are to be enabled) | |
| SMTP_PASSWORD | Password for your SMTP Server. | optional (required if email services are to be enabled) | |
| SMTP_SECURE_ENABLED | SMTP secure connection. For using TLS, set to 1 else to 0. | optional (required if email services are to be enabled) | |
| GITHUB_ID | Client ID for GitHub. | optional (required if GitHub auth is enabled) | |
| GITHUB_SECRET | Secret for GitHub. | optional (required if GitHub auth is enabled) | |
| GOOGLE_CLIENT_ID | Client ID for Google. | optional (required if Google auth is enabled) | |
| GOOGLE_CLIENT_SECRET | Secret for Google. | optional (required if Google auth is enabled) | |
| CRON_SECRET | API Secret for running cron jobs. | optional | |
| STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | |
| STRIPE_WEBHOOK_SECRET | Webhook secret for Stripe integration. | optional | |
| TELEMETRY_DISABLED | Disables telemetry if set to 1. | optional | |
| INSTANCE_ID | Instance ID for Formbricks Cloud to be sent to Telemetry. | optional | |
| INTERNAL_SECRET | Internal Secret (Currently we overwrite the value with a random value). | optional | |
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
| DEFAULT_ORGANIZATION_ID | Automatically assign new users to a specific organization when joining | optional | |
| DEFAULT_ORGANIZATION_ROLE | Role of the user in the default organization. | optional | admin |
| OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | |
| OIDC_CLIENT_ID | Client ID for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
| OIDC_CLIENT_SECRET | Secret for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
| OIDC_ISSUER | Issuer URL for Custom OpenID Connect Provider (should have .well-known configured at this) | optional (required if OIDC auth is enabled) | |
| OIDC_SIGNING_ALGORITHM | Signing Algorithm for Custom OpenID Connect Provider | optional | RS256 |
| OPENTELEMETRY_LISTENER_URL | URL for OpenTelemetry listener inside Formbricks. | optional | |
| CUSTOM_CACHE_DISABLED | Disables custom cache handler if set to 1 (required for deployment on Vercel) | optional | |
| `<add more>` | | | |
| | | | |
Note: If you want to configure something that is not possible via above, please open an issue on our GitHub repo here or reach out to us on Discord and well try our best to work out a solution with you.

View File

@@ -83,22 +83,7 @@ Next, you need to generate an Encryption Key. This will be used for authenticati
</Col>
5. **Generate Cron Secret**
Next, you need to generate a Cron secret. This will be used as an API Secret for running cron jobs. The `sed` command below generates a random string using `openssl`, then replaces the `CRON_SECRET:` placeholder in the `docker-compose.yml` file with this generated secret:
<Col>
<CodeGroup title="Generate Cron Secret">
```bash
sed -i "/CRON_SECRET:$/s/CRON_SECRET:.*/CRON_SECRET: $(openssl rand -hex 32)/" docker-compose.yml
```
</CodeGroup>
</Col>
6. **Start the Docker Setup**
5. **Start the Docker Setup**
You're now ready to start the Formbricks Docker setup. The following command will start Formbricks together with a postgreSQL database using Docker Compose:
@@ -113,7 +98,7 @@ You're now ready to start the Formbricks Docker setup. The following command wil
</Col>
The `-d` flag will run the containers in detached mode, meaning they'll run in the background.
7. **Visit Formbricks in Your Browser**
6. **Visit Formbricks in Your Browser**
After starting the Docker setup, visit http://localhost:3000 in your browser to interact with the Formbricks application. The first time you access this page, you'll be greeted by a setup wizard. Follow the prompts to define your first user and get started.

View File

@@ -677,9 +677,6 @@ x-environment: &environment
# The url of your Formbricks instance used in the admin panel
WEBAPP_URL:
# Required for next-auth. Should be the same as WEBAPP_URL
NEXTAUTH_URL:
# PostgreSQL DB for Formbricks to connect to
DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/formbricks?schema=public"
@@ -688,6 +685,10 @@ x-environment: &environment
# You can use: `openssl rand -hex 32` to generate one
NEXTAUTH_SECRET:
# Set this to your public-facing URL, e.g., https://example.com
# You do not need the NEXTAUTH_URL environment variable in Vercel.
NEXTAUTH_URL: http://localhost:3000
# PostgreSQL password
POSTGRES_PASSWORD: postgres

View File

@@ -71,18 +71,18 @@ Refer to our [Example HTML project](https://github.com/formbricks/examples/tree/
## ReactJS
Install the Formbricks SDK using one of the package managers ie `npm`,`pnpm`,`yarn`. Note that zod is required as a peer dependency and must also be installed in your project.
Install the Formbricks SDK using one of the package managers ie `npm`,`pnpm`,`yarn`.
<Col>
<CodeGroup title="Install Formbricks JS library">
```shell {{ title: 'npm' }}
npm install @formbricks/js zod
npm install @formbricks/js
```
```shell {{ title: 'pnpm' }}
pnpm add @formbricks/js zod
pnpm add @formbricks/js
```
```shell {{ title: 'yarn' }}
yarn add @formbricks/js zod
yarn add @formbricks/js
```
</CodeGroup>
@@ -142,13 +142,13 @@ Code snippets for the integration for both conventions are provided to further a
<Col>
<CodeGroup title="Install Formbricks JS library">
```shell {{ title: 'npm' }}
npm install @formbricks/js zod
npm install @formbricks/js
```
```shell {{ title: 'pnpm' }}
pnpm add @formbricks/js zod
pnpm add @formbricks/js
```
```shell {{ title: 'yarn' }}
yarn add @formbricks/js zod
yarn add @formbricks/js
```
</CodeGroup>
@@ -164,6 +164,7 @@ yarn add @formbricks/js zod
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import formbricks from "@formbricks/js/website";
export default function FormbricksProvider() {
@@ -216,6 +217,7 @@ Refer to our [Example NextJS App Directory project](https://github.com/formbrick
// other import
import { useRouter } from "next/router";
import { useEffect } from "react";
import formbricks from "@formbricks/js/website";
if (typeof window !== "undefined") {

View File

@@ -84,7 +84,6 @@ export const navigation: Array<NavGroup> = [
{ title: "Hidden Fields", href: "/link-surveys/hidden-fields" },
{ title: "Start At Question", href: "/link-surveys/start-at-question" },
{ title: "Embed Surveys Anywhere", href: "/link-surveys/embed-surveys" },
{ title: "Market Research Panel", href: "/link-surveys/market-research-panel" },
{ title: "Multi Language Surveys", href: "/global/multi-language-surveys" },
{ title: "User Metadata", href: "/global/metadata" },
{ title: "Custom Styling", href: "/global/overwrite-styling" }, // global

View File

@@ -27,7 +27,6 @@ RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq
ENV DATABASE_URL="postgresql://placeholder:for@build:5432/gets_overwritten_at_runtime?schema=public"
ENV NEXTAUTH_SECRET="placeholder_for_next_auth_of_64_chars_get_overwritten_at_runtime"
ENV ENCRYPTION_KEY="placeholder_for_build_key_of_64_chars_get_overwritten_at_runtime"
ENV CRON_SECRET="placeholder_for_cron_secret_of_64_chars_get_overwritten_at_runtime"
ARG NEXT_PUBLIC_SENTRY_DSN
ARG SENTRY_AUTH_TOKEN

View File

@@ -7,7 +7,6 @@ import Image from "next/image";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
import { PREVIEW_SURVEY } from "@formbricks/lib/styling/constants";
import {
TProductConfigChannel,
@@ -46,27 +45,18 @@ export const ProductSettings = ({
const addProduct = async (data: TProductUpdateInput) => {
try {
const createProductResponse = await createProductAction({
organizationId,
data: {
...data,
config: { channel, industry },
},
const product = await createProductAction(organizationId, {
...data,
config: { channel, industry },
});
if (createProductResponse?.data) {
// get production environment
const productionEnvironment = createProductResponse.data.environments.find(
(environment) => environment.type === "production"
);
if (channel !== "link") {
router.push(`/environments/${productionEnvironment?.id}/connect`);
} else {
router.push(`/environments/${productionEnvironment?.id}/surveys`);
}
// get production environment
const productionEnvironment = product.environments.find(
(environment) => environment.type === "production"
);
if (channel !== "link") {
router.push(`/environments/${productionEnvironment?.id}/connect`);
} else {
const errorMessage = getFormattedErrorMessage(createProductResponse);
toast.error(errorMessage);
router.push(`/environments/${productionEnvironment?.id}/surveys`);
}
} catch (error) {
toast.error("Product creation failed");

View File

@@ -23,11 +23,11 @@ import {
loadNewSegmentInSurvey,
updateSurvey,
} from "@formbricks/lib/survey/service";
import { TActionClassInput } from "@formbricks/types/action-classes";
import { TActionClassInput } from "@formbricks/types/actionClasses";
import { AuthorizationError } from "@formbricks/types/errors";
import { TProduct } from "@formbricks/types/product";
import { TBaseFilters, TSegmentUpdateInput, ZSegmentFilters } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys";
export const surveyMutateAction = async (survey: TSurvey): Promise<TSurvey> => {
return await updateSurvey(survey);

View File

@@ -1,7 +1,7 @@
"use client";
import { TActionClass } from "@formbricks/types/action-classes";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TSurvey } from "@formbricks/types/surveys";
import { ModalWithTabs } from "@formbricks/ui/ModalWithTabs";
import { CreateNewActionTab } from "./CreateNewActionTab";
import { SavedActionsTab } from "./SavedActionsTab";

View File

@@ -2,8 +2,8 @@
import { PlusIcon } from "lucide-react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyAddressQuestion } from "@formbricks/types/surveys/types";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyAddressQuestion } from "@formbricks/types/surveys";
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
import { Button } from "@formbricks/ui/Button";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";

View File

@@ -1,5 +1,5 @@
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys";
import { LogicEditor } from "./LogicEditor";
import { UpdateQuestionId } from "./UpdateQuestionId";

View File

@@ -5,7 +5,7 @@ import { CheckIcon } from "lucide-react";
import { UseFormReturn } from "react-hook-form";
import { cn } from "@formbricks/lib/cn";
import { TProductStyling } from "@formbricks/types/product";
import { TSurveyStyling } from "@formbricks/types/surveys/types";
import { TSurveyStyling } from "@formbricks/types/surveys";
import { Badge } from "@formbricks/ui/Badge";
import { FormControl, FormDescription, FormField, FormItem, FormLabel } from "@formbricks/ui/Form";
import { Slider } from "@formbricks/ui/Slider";

View File

@@ -2,8 +2,8 @@
import { useState } from "react";
import { LocalizedEditor } from "@formbricks/ee/multi-language/components/localized-editor";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyCTAQuestion } from "@formbricks/types/surveys/types";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyCTAQuestion } from "@formbricks/types/surveys";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";

View File

@@ -1,8 +1,8 @@
import { PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyCalQuestion } from "@formbricks/types/surveys/types";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyCalQuestion } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { Checkbox } from "@formbricks/ui/Checkbox";
import { Input } from "@formbricks/ui/Input";

View File

@@ -7,7 +7,7 @@ import { UseFormReturn } from "react-hook-form";
import { cn } from "@formbricks/lib/cn";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { TProduct, TProductStyling } from "@formbricks/types/product";
import { TSurveyStyling, TSurveyType } from "@formbricks/types/surveys/types";
import { TSurveyStyling, TSurveyType } from "@formbricks/types/surveys";
import { Badge } from "@formbricks/ui/Badge";
import { CardArrangementTabs } from "@formbricks/ui/CardArrangementTabs";
import { ColorPicker } from "@formbricks/ui/ColorPicker";

View File

@@ -2,8 +2,8 @@
import { useState } from "react";
import { LocalizedEditor } from "@formbricks/ee/multi-language/components/localized-editor";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyConsentQuestion } from "@formbricks/types/surveys/types";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyConsentQuestion } from "@formbricks/types/surveys";
import { Label } from "@formbricks/ui/Label";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";

View File

@@ -9,8 +9,8 @@ import {
TActionClassInput,
TActionClassInputCode,
ZActionClassInput,
} from "@formbricks/types/action-classes";
import { TSurvey } from "@formbricks/types/surveys/types";
} from "@formbricks/types/actionClasses";
import { TSurvey } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@formbricks/ui/Form";
import { Input } from "@formbricks/ui/Input";

View File

@@ -1,7 +1,7 @@
import { PlusIcon } from "lucide-react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyDateQuestion } from "@formbricks/types/surveys/types";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyDateQuestion } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { Label } from "@formbricks/ui/Label";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";

View File

@@ -4,8 +4,8 @@ import * as Collapsible from "@radix-ui/react-collapsible";
import { useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey } from "@formbricks/types/surveys";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";

View File

@@ -5,8 +5,8 @@ import { usePathname } from "next/navigation";
import { useState } from "react";
import { LocalizedEditor } from "@formbricks/ee/multi-language/components/localized-editor";
import { cn } from "@formbricks/lib/cn";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey } from "@formbricks/types/surveys";
import { FileInput } from "@formbricks/ui/FileInput";
import { Label } from "@formbricks/ui/Label";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";

View File

@@ -7,10 +7,10 @@ import { toast } from "react-hot-toast";
import { extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { createI18nString } from "@formbricks/lib/i18n/utils";
import { useGetBillingInfo } from "@formbricks/lib/organization/hooks/useGetBillingInfo";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/common";
import { TProduct } from "@formbricks/types/product";
import { TSurvey, TSurveyFileUploadQuestion } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyFileUploadQuestion } from "@formbricks/types/surveys";
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";

View File

@@ -8,7 +8,7 @@ import { cn } from "@formbricks/lib/cn";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { mixColor } from "@formbricks/lib/utils/colors";
import { TProductStyling } from "@formbricks/types/product";
import { TSurveyStyling } from "@formbricks/types/surveys/types";
import { TSurveyStyling } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { ColorPicker } from "@formbricks/ui/ColorPicker";
import { FormControl, FormDescription, FormField, FormItem, FormLabel } from "@formbricks/ui/Form";

View File

@@ -4,13 +4,13 @@ import * as Collapsible from "@radix-ui/react-collapsible";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { TSurvey, TSurveyHiddenFields } from "@formbricks/types/surveys/types";
import { validateId } from "@formbricks/types/surveys/validation";
import { TSurvey, TSurveyHiddenFields } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { Switch } from "@formbricks/ui/Switch";
import { Tag } from "@formbricks/ui/Tag";
import { validateId } from "../lib/validation";
interface HiddenFieldsCardProps {
localSurvey: TSurvey;
@@ -119,24 +119,14 @@ export const HiddenFieldsCard = ({
e.preventDefault();
const existingQuestionIds = localSurvey.questions.map((question) => question.id);
const existingHiddenFieldIds = localSurvey.hiddenFields.fieldIds ?? [];
const validateIdError = validateId(
"Hidden field",
hiddenField,
existingQuestionIds,
existingHiddenFieldIds
);
if (validateIdError) {
toast.error(validateIdError);
return;
if (validateId("Hidden field", hiddenField, existingQuestionIds, existingHiddenFieldIds)) {
updateSurvey({
fieldIds: [...(localSurvey.hiddenFields?.fieldIds || []), hiddenField],
enabled: true,
});
toast.success("Hidden field added successfully");
setHiddenField("");
}
updateSurvey({
fieldIds: [...(localSurvey.hiddenFields?.fieldIds || []), hiddenField],
enabled: true,
});
toast.success("Hidden field added successfully");
setHiddenField("");
}}>
<Label htmlFor="headline">Hidden Field</Label>
<div className="mt-2 flex gap-2">

View File

@@ -8,7 +8,7 @@ import { cn } from "@formbricks/lib/cn";
import { TEnvironment } from "@formbricks/types/environment";
import { TProduct } from "@formbricks/types/product";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey, TSurveyType } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyType } from "@formbricks/types/surveys";
import { Badge } from "@formbricks/ui/Badge";
import { Label } from "@formbricks/ui/Label";
import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup";

View File

@@ -11,14 +11,14 @@ import { toast } from "react-hot-toast";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import {
TSurvey,
TSurveyLogic,
TSurveyLogicCondition,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
} from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import {
DropdownMenu,

View File

@@ -1,9 +1,10 @@
"use client";
import { PlusIcon, TrashIcon } from "lucide-react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types";
import { toast } from "react-hot-toast";
import { createI18nString, extractLanguageCodes, getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { Label } from "@formbricks/ui/Label";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
@@ -78,6 +79,25 @@ export const MatrixQuestionForm = ({
}
};
const checkForDuplicateLabels = () => {
const rowLabels = question.rows
.map((row) => getLocalizedValue(row, selectedLanguageCode))
.filter((label) => label.trim() !== "");
const columnLabels = question.columns
.map((column) => getLocalizedValue(column, selectedLanguageCode))
.filter((label) => label.trim() !== "");
const duplicateRowLabels = rowLabels.filter((label, index, array) => array.indexOf(label) !== index);
const duplicateColumnLabels = columnLabels.filter(
(label, index, array) => array.indexOf(label) !== index
);
if (duplicateRowLabels.length > 0 || duplicateColumnLabels.length > 0) {
toast.error("Duplicate row or column labels");
}
};
return (
<form>
<QuestionFormInput
@@ -144,6 +164,7 @@ export const MatrixQuestionForm = ({
updateMatrixLabel={updateMatrixLabel}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
onBlur={checkForDuplicateLabels}
isInvalid={
isInvalid && !isLabelValidForAllLanguages(question.rows[index], localSurvey.languages)
}
@@ -186,6 +207,7 @@ export const MatrixQuestionForm = ({
updateMatrixLabel={updateMatrixLabel}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
onBlur={checkForDuplicateLabels}
isInvalid={
isInvalid && !isLabelValidForAllLanguages(question.columns[index], localSurvey.languages)
}

View File

@@ -7,14 +7,14 @@ import { PlusIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import {
TI18nString,
TShuffleOption,
TSurvey,
TSurveyMultipleChoiceQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
} from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { Label } from "@formbricks/ui/Label";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
@@ -68,6 +68,20 @@ export const MultipleChoiceQuestionForm = ({
},
};
const findDuplicateLabel = () => {
for (let i = 0; i < question.choices.length; i++) {
for (let j = i + 1; j < question.choices.length; j++) {
if (
getLocalizedValue(question.choices[i].label, selectedLanguageCode).trim() ===
getLocalizedValue(question.choices[j].label, selectedLanguageCode).trim()
) {
return getLocalizedValue(question.choices[i].label, selectedLanguageCode).trim(); // Return the duplicate label
}
}
}
return null;
};
const updateChoice = (choiceIdx: number, updatedAttributes: { label: TI18nString }) => {
const newLabel = updatedAttributes.label.en;
const oldLabel = question.choices[choiceIdx].label;
@@ -253,11 +267,13 @@ export const MultipleChoiceQuestionForm = ({
updateChoice={updateChoice}
deleteChoice={deleteChoice}
addChoice={addChoice}
setisInvalidValue={setisInvalidValue}
isInvalid={isInvalid}
localSurvey={localSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
surveyLanguages={surveyLanguages}
findDuplicateLabel={findDuplicateLabel}
question={question}
updateQuestion={updateQuestion}
surveyLanguageCodes={surveyLanguageCodes}

View File

@@ -2,8 +2,8 @@
import { PlusIcon } from "lucide-react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyNPSQuestion } from "@formbricks/types/surveys/types";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyNPSQuestion } from "@formbricks/types/surveys";
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
import { Button } from "@formbricks/ui/Button";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";

View File

@@ -2,12 +2,12 @@
import { HashIcon, LinkIcon, MailIcon, MessageSquareTextIcon, PhoneIcon, PlusIcon } from "lucide-react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import {
TSurvey,
TSurveyOpenTextQuestion,
TSurveyOpenTextQuestionInputType,
} from "@formbricks/types/surveys/types";
} from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { Label } from "@formbricks/ui/Label";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";

View File

@@ -2,8 +2,8 @@ import { createId } from "@paralleldrive/cuid2";
import { PlusIcon } from "lucide-react";
import { cn } from "@formbricks/lib/cn";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyPictureSelectionQuestion } from "@formbricks/types/surveys/types";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyPictureSelectionQuestion } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { FileInput } from "@formbricks/ui/FileInput";
import { Label } from "@formbricks/ui/Label";

View File

@@ -8,14 +8,9 @@ import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react";
import { useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { recallToHeadline } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TProduct } from "@formbricks/types/product";
import {
TI18nString,
TSurvey,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TI18nString, TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys";
import { Label } from "@formbricks/ui/Label";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
import { Switch } from "@formbricks/ui/Switch";

View File

@@ -5,7 +5,7 @@ import { createId } from "@paralleldrive/cuid2";
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react";
import React, { useState } from "react";
import { TProduct } from "@formbricks/types/product";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys";
import { ConfirmationModal } from "@formbricks/ui/ConfirmationModal";
import {
DropdownMenu,

View File

@@ -1,7 +1,7 @@
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TProduct } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys";
import { QuestionCard } from "./QuestionCard";
interface QuestionsDraggableProps {

View File

@@ -1,7 +1,7 @@
import { PaintbrushIcon, Rows3Icon, SettingsIcon } from "lucide-react";
import { useMemo } from "react";
import { cn } from "@formbricks/lib/cn";
import { TSurveyEditorTabs } from "@formbricks/types/surveys/types";
import { TSurveyEditorTabs } from "@formbricks/types/surveys";
interface Tab {
id: TSurveyEditorTabs;

View File

@@ -15,11 +15,15 @@ import { MultiLanguageCard } from "@formbricks/ee/multi-language/components/mult
import { extractLanguageCodes, getLocalizedValue, translateQuestion } from "@formbricks/lib/i18n/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { checkForEmptyFallBackValue, extractRecallInfo } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TProduct } from "@formbricks/types/product";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { findQuestionsWithCyclicLogic } from "@formbricks/types/surveys/validation";
import { isCardValid, validateQuestion, validateSurveyQuestionsInBatch } from "../lib/validation";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys";
import {
findQuestionsWithCyclicLogic,
isCardValid,
validateQuestion,
validateSurveyQuestionsInBatch,
} from "../lib/validation";
import { AddQuestionButton } from "./AddQuestionButton";
import { EditThankYouCard } from "./EditThankYouCard";
import { EditWelcomeCard } from "./EditWelcomeCard";
@@ -33,7 +37,7 @@ interface QuestionsViewProps {
setActiveQuestionId: (questionId: string | null) => void;
product: TProduct;
invalidQuestions: string[] | null;
setInvalidQuestions: React.Dispatch<SetStateAction<string[] | null>>;
setInvalidQuestions: (invalidQuestions: string[] | null) => void;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
isMultiLanguageAllowed?: boolean;
@@ -88,24 +92,19 @@ export const QuestionsView = ({
if (invalidQuestions === null) {
return;
}
const isFirstQuestion = question.id === localSurvey.questions[0].id;
let temp = structuredClone(invalidQuestions);
if (validateQuestion(question, surveyLanguages, isFirstQuestion)) {
// If question is valid, we now check for cyclic logic
const questionsWithCyclicLogic = findQuestionsWithCyclicLogic(localSurvey.questions);
if (questionsWithCyclicLogic.includes(question.id) && !invalidQuestions.includes(question.id)) {
setInvalidQuestions([...invalidQuestions, question.id]);
return;
if (!questionsWithCyclicLogic.includes(question.id)) {
temp = invalidQuestions.filter((id) => id !== question.id);
setInvalidQuestions(temp);
}
setInvalidQuestions(invalidQuestions.filter((id) => id !== question.id));
return;
} else if (!invalidQuestions.includes(question.id)) {
temp.push(question.id);
setInvalidQuestions(temp);
}
setInvalidQuestions([...invalidQuestions, question.id]);
return;
};
const updateQuestion = (questionIdx: number, updatedAttributes: any) => {
@@ -126,7 +125,6 @@ export const QuestionsView = ({
delete internalQuestionIdMap[localSurvey.questions[questionIdx].id];
setActiveQuestionId(updatedAttributes.id);
}
updatedSurvey.questions[questionIdx] = {
...updatedSurvey.questions[questionIdx],
...updatedAttributes,

View File

@@ -1,7 +1,7 @@
import { HashIcon, PlusIcon, SmileIcon, StarIcon } from "lucide-react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyRatingQuestion } from "@formbricks/types/surveys/types";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyRatingQuestion } from "@formbricks/types/surveys";
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
import { Button } from "@formbricks/ui/Button";
import { Label } from "@formbricks/ui/Label";

View File

@@ -4,7 +4,7 @@ import * as Collapsible from "@radix-ui/react-collapsible";
import { CheckIcon } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys";
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";

View File

@@ -6,7 +6,7 @@ import Link from "next/link";
import { KeyboardEventHandler, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys";
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
import { DatePicker } from "@formbricks/ui/DatePicker";
import { Input } from "@formbricks/ui/Input";
@@ -293,12 +293,7 @@ export const ResponseOptionsCard = ({
};
const handleInputResponse = (e) => {
let value = parseInt(e.target.value);
if (Number.isNaN(value) || value < 1) {
value = 1;
}
const updatedSurvey = { ...localSurvey, autoComplete: value };
const updatedSurvey = { ...localSurvey, autoComplete: parseInt(e.target.value) };
setLocalSurvey(updatedSurvey);
};
@@ -348,7 +343,7 @@ export const ResponseOptionsCard = ({
description="Automatically close the survey after a certain number of responses."
childBorder={true}>
<label htmlFor="autoCompleteResponses" className="cursor-pointer bg-slate-50 p-4">
<p className="text-sm font-semibold text-slate-700">
<p className="text-sm text-slate-700">
Automatically mark the survey as complete after
<Input
autoFocus

View File

@@ -1,7 +1,7 @@
import { Code2Icon, MousePointerClickIcon, SparklesIcon } from "lucide-react";
import { useState } from "react";
import { TActionClass } from "@formbricks/types/action-classes";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TSurvey } from "@formbricks/types/surveys";
import { Input } from "@formbricks/ui/Input";
interface SavedActionsTabProps {

View File

@@ -1,15 +1,16 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVerticalIcon, PlusIcon, TrashIcon } from "lucide-react";
import toast from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { createI18nString } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import {
TI18nString,
TSurvey,
TSurveyLanguage,
TSurveyMultipleChoiceQuestion,
} from "@formbricks/types/surveys/types";
} from "@formbricks/types/surveys";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
import { isLabelValidForAllLanguages } from "../lib/validation";
@@ -23,11 +24,13 @@ interface ChoiceProps {
updateChoice: (choiceIdx: number, updatedAttributes: { label: TI18nString }) => void;
deleteChoice: (choiceIdx: number) => void;
addChoice: (choiceIdx: number) => void;
setisInvalidValue: (value: string | null) => void;
isInvalid: boolean;
localSurvey: TSurvey;
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
surveyLanguages: TSurveyLanguage[];
findDuplicateLabel: () => string | null;
question: TSurveyMultipleChoiceQuestion;
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyMultipleChoiceQuestion>) => void;
surveyLanguageCodes: string[];
@@ -44,8 +47,10 @@ export const SelectQuestionChoice = ({
questionIdx,
selectedLanguageCode,
setSelectedLanguageCode,
setisInvalidValue,
surveyLanguages,
updateChoice,
findDuplicateLabel,
question,
surveyLanguageCodes,
updateQuestion,
@@ -78,6 +83,15 @@ export const SelectQuestionChoice = ({
localSurvey={localSurvey}
questionIdx={questionIdx}
value={choice.label}
onBlur={() => {
const duplicateLabel = findDuplicateLabel();
if (duplicateLabel) {
toast.error("Duplicate choices");
setisInvalidValue(duplicateLabel);
} else {
setisInvalidValue(null);
}
}}
updateChoice={updateChoice}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}

View File

@@ -1,11 +1,11 @@
import { AdvancedTargetingCard } from "@formbricks/ee/advanced-targeting/components/advanced-targeting-card";
import { TActionClass } from "@formbricks/types/action-classes";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TEnvironment } from "@formbricks/types/environment";
import { TMembershipRole } from "@formbricks/types/memberships";
import { TProduct } from "@formbricks/types/product";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys";
import { HowToSendCard } from "./HowToSendCard";
import { RecontactOptionsCard } from "./RecontactOptionsCard";
import { ResponseOptionsCard } from "./ResponseOptionsCard";

View File

@@ -7,7 +7,7 @@ import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { TEnvironment } from "@formbricks/types/environment";
import { TProduct, TProductStyling } from "@formbricks/types/product";
import { TBaseStyling } from "@formbricks/types/styling";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys";
import { AlertDialog } from "@formbricks/ui/AlertDialog";
import { Button } from "@formbricks/ui/Button";
import {

View File

@@ -4,13 +4,13 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { extractLanguageCodes, getEnabledLanguages } from "@formbricks/lib/i18n/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { useDocumentVisibility } from "@formbricks/lib/useDocumentVisibility";
import { TActionClass } from "@formbricks/types/action-classes";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TEnvironment } from "@formbricks/types/environment";
import { TMembershipRole } from "@formbricks/types/memberships";
import { TProduct } from "@formbricks/types/product";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey, TSurveyEditorTabs, TSurveyStyling } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyEditorTabs, TSurveyStyling } from "@formbricks/types/surveys";
import { PreviewSurvey } from "@formbricks/ui/PreviewSurvey";
import { refetchProductAction } from "../actions";
import { LoadingSkeleton } from "./LoadingSkeleton";

View File

@@ -7,11 +7,10 @@ import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { createSegmentAction } from "@formbricks/ee/advanced-targeting/lib/actions";
import { getLanguageLabel } from "@formbricks/lib/i18n/utils";
import { TEnvironment } from "@formbricks/types/environment";
import { TProduct } from "@formbricks/types/product";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey, TSurveyEditorTabs, TSurveyQuestion, ZSurvey } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyEditorTabs } from "@formbricks/types/surveys";
import { AlertDialog } from "@formbricks/ui/AlertDialog";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
@@ -26,7 +25,7 @@ interface SurveyMenuBarProps {
environment: TEnvironment;
activeId: TSurveyEditorTabs;
setActiveId: React.Dispatch<React.SetStateAction<TSurveyEditorTabs>>;
setInvalidQuestions: React.Dispatch<React.SetStateAction<string[]>>;
setInvalidQuestions: (invalidQuestions: string[]) => void;
product: TProduct;
responseCount: number;
selectedLanguageCode: string;
@@ -44,6 +43,7 @@ export const SurveyMenuBar = ({
product,
responseCount,
selectedLanguageCode,
setSelectedLanguageCode,
}: SurveyMenuBarProps) => {
const router = useRouter();
const [audiencePrompt, setAudiencePrompt] = useState(true);
@@ -53,6 +53,8 @@ export const SurveyMenuBar = ({
const [isSurveySaving, setIsSurveySaving] = useState(false);
const cautionText = "This survey received responses.";
const faultyQuestions: string[] = [];
useEffect(() => {
if (audiencePrompt && activeId === "settings") {
setAudiencePrompt(false);
@@ -138,65 +140,20 @@ export const SurveyMenuBar = ({
return localSurvey.segment;
};
const validateSurveyWithZod = (): boolean => {
const localSurveyValidation = ZSurvey.safeParse(localSurvey);
if (!localSurveyValidation.success) {
const currentError = localSurveyValidation.error.errors[0];
if (currentError.path[0] === "questions") {
const questionIdx = currentError.path[1];
const question: TSurveyQuestion = localSurvey.questions[questionIdx];
if (question) {
setInvalidQuestions((prevInvalidQuestions) =>
prevInvalidQuestions ? [...prevInvalidQuestions, question.id] : [question.id]
);
}
} else if (currentError.path[0] === "welcomeCard") {
setInvalidQuestions((prevInvalidQuestions) =>
prevInvalidQuestions ? [...prevInvalidQuestions, "start"] : ["start"]
);
} else if (currentError.path[0] === "thankYouCard") {
setInvalidQuestions((prevInvalidQuestions) =>
prevInvalidQuestions ? [...prevInvalidQuestions, "end"] : ["end"]
);
}
if (currentError.code === "custom") {
const params = currentError.params ?? ({} as { invalidLanguageCodes: string[] });
if (params.invalidLanguageCodes && params.invalidLanguageCodes.length) {
const invalidLanguageLabels = params.invalidLanguageCodes.map(
(invalidLanguage: string) => getLanguageLabel(invalidLanguage) ?? invalidLanguage
);
toast.error(`${currentError.message} ${invalidLanguageLabels.join(", ")}`);
} else {
toast.error(currentError.message);
}
return false;
}
toast.error(currentError.message);
return false;
}
return true;
};
const handleSurveySave = async (): Promise<boolean> => {
const handleSurveySave = async () => {
setIsSurveySaving(true);
const isSurveyValidatedWithZod = validateSurveyWithZod();
if (!isSurveyValidatedWithZod) {
setIsSurveySaving(false);
return false;
}
try {
const isSurveyValidResult = isSurveyValid(localSurvey, selectedLanguageCode);
if (!isSurveyValidResult) {
if (
!isSurveyValid(
localSurvey,
faultyQuestions,
setInvalidQuestions,
selectedLanguageCode,
setSelectedLanguageCode
)
) {
setIsSurveySaving(false);
return false;
return;
}
localSurvey.questions = localSurvey.questions.map((question) => {
@@ -211,36 +168,31 @@ export const SurveyMenuBar = ({
setLocalSurvey(updatedSurvey);
toast.success("Changes saved.");
return true;
} catch (e) {
console.error(e);
setIsSurveySaving(false);
toast.error(`Error saving changes`);
return false;
return;
}
};
const handleSaveAndGoBack = async () => {
const isSurveySaved = await handleSurveySave();
if (isSurveySaved) {
router.back();
}
await handleSurveySave();
router.back();
};
const handleSurveyPublish = async () => {
setIsSurveyPublishing(true);
const isSurveyValidatedWithZod = validateSurveyWithZod();
if (!isSurveyValidatedWithZod) {
setIsSurveyPublishing(false);
return;
}
try {
const isSurveyValidResult = isSurveyValid(localSurvey, selectedLanguageCode);
if (!isSurveyValidResult) {
if (
!isSurveyValid(
localSurvey,
faultyQuestions,
setInvalidQuestions,
selectedLanguageCode,
setSelectedLanguageCode
)
) {
setIsSurveyPublishing(false);
return;
}
@@ -310,8 +262,7 @@ export const SurveyMenuBar = ({
variant="secondary"
className="mr-3"
loading={isSurveySaving}
onClick={() => handleSurveySave()}
type="submit">
onClick={() => handleSurveySave()}>
Save
</Button>
{localSurvey.status !== "draft" && (

View File

@@ -5,7 +5,7 @@ import { CheckIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { TPlacement } from "@formbricks/types/common";
import { TSurvey, TSurveyProductOverwrites } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyProductOverwrites } from "@formbricks/types/surveys";
import { Label } from "@formbricks/ui/Label";
import { Switch } from "@formbricks/ui/Switch";
import { Placement } from "./Placement";

View File

@@ -9,9 +9,9 @@ import { toast } from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { isAdvancedSegment } from "@formbricks/lib/segment/utils";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TBaseFilter, TSegment, TSegmentCreateInput, TSegmentUpdateInput } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys";
import { AlertDialog } from "@formbricks/ui/AlertDialog";
import { BasicAddFilterModal } from "@formbricks/ui/BasicAddFilterModal";
import { BasicSegmentEditor } from "@formbricks/ui/BasicSegmentEditor";

View File

@@ -5,7 +5,7 @@ import { SearchIcon } from "lucide-react";
import UnsplashImage from "next/image";
import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { TSurveyBackgroundBgType } from "@formbricks/types/surveys/types";
import { TSurveyBackgroundBgType } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { LoadingSpinner } from "@formbricks/ui/LoadingSpinner";

View File

@@ -2,11 +2,11 @@
import { useState } from "react";
import toast from "react-hot-toast";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { validateId } from "@formbricks/types/surveys/validation";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { validateId } from "../lib/validation";
interface UpdateQuestionIdProps {
localSurvey: TSurvey;
@@ -35,20 +35,14 @@ export const UpdateQuestionId = ({
const questionIds = localSurvey.questions.map((q) => q.id);
const hiddenFieldIds = localSurvey.hiddenFields.fieldIds ?? [];
const validateIdError = validateId("Question", currentValue, questionIds, hiddenFieldIds);
if (validateIdError) {
setIsInputInvalid(true);
toast.error(validateIdError);
if (validateId("Question", currentValue, questionIds, hiddenFieldIds)) {
setIsInputInvalid(false);
toast.success("Question ID updated.");
updateQuestion(questionIdx, { id: currentValue });
setPrevValue(currentValue); // after successful update, set current value as previous value
} else {
setCurrentValue(prevValue);
return;
}
setIsInputInvalid(false);
toast.success("Question ID updated.");
updateQuestion(questionIdx, { id: currentValue });
setPrevValue(currentValue); // after successful update, set current value as previous value
};
const isButtonDisabled = () => {

View File

@@ -11,9 +11,9 @@ import {
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TActionClass } from "@formbricks/types/action-classes";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TMembershipRole } from "@formbricks/types/memberships";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys";
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
@@ -86,9 +86,7 @@ export const WhenToSendCard = ({
const handleInputSeconds = (e: any) => {
let value = parseInt(e.target.value);
if (value < 1 || Number.isNaN(value)) {
value = 0;
}
if (value < 1) value = 1;
const updatedSurvey = { ...localSurvey, autoClose: value };
setLocalSurvey(updatedSurvey);
@@ -96,28 +94,27 @@ export const WhenToSendCard = ({
const handleTriggerDelay = (e: any) => {
let value = parseInt(e.target.value);
if (value < 1 || Number.isNaN(value)) {
value = 0;
}
const updatedSurvey = { ...localSurvey, delay: value };
setLocalSurvey(updatedSurvey);
};
const handleRandomizerInput = (e) => {
let value = parseFloat(e.target.value);
let value: number | null = null;
if (Number.isNaN(value)) {
value = 0.01;
if (e.target.value !== "") {
value = parseFloat(e.target.value);
if (Number.isNaN(value)) {
value = 1;
}
if (value < 0.01) value = 0.01;
if (value > 100) value = 100;
// Round value to two decimal places. eg: 10.555(and higher like 10.556) -> 10.56 and 10.554(and lower like 10.553) ->10.55
value = Math.round(value * 100) / 100;
}
if (value < 0.01) value = 0.01;
if (value > 100) value = 100;
// Round value to two decimal places. eg: 10.555(and higher like 10.556) -> 10.56 and 10.554(and lower like 10.553) ->10.55
value = Math.round(value * 100) / 100;
const updatedSurvey = { ...localSurvey, displayPercentage: value };
setLocalSurvey(updatedSurvey);
};

View File

@@ -1,4 +1,5 @@
// extend this object in order to add more validation rules
import { isEqual } from "lodash";
import { toast } from "react-hot-toast";
import { extractLanguageCodes, getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { checkForEmptyFallBackValue } from "@formbricks/lib/utils/recall";
@@ -14,10 +15,11 @@ import {
TSurveyOpenTextQuestion,
TSurveyPictureSelectionQuestion,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveyQuestions,
TSurveyThankYouCard,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
import { findLanguageCodesForDuplicateLabels } from "@formbricks/types/surveys/validation";
} from "@formbricks/types/surveys";
// Utility function to check if label is valid for all required languages
export const isLabelValidForAllLanguages = (
@@ -37,31 +39,37 @@ const handleI18nCheckForMultipleChoice = (
question: TSurveyMultipleChoiceQuestion,
languages: TSurveyLanguage[]
): boolean => {
const invalidLangCodes = findLanguageCodesForDuplicateLabels(
question.choices.map((choice) => choice.label),
languages
);
if (invalidLangCodes.length > 0) {
return false;
}
return question.choices.every((choice) => isLabelValidForAllLanguages(choice.label, languages));
};
const hasDuplicates = (labels: TI18nString[]) => {
const flattenedLabels = labels
.map((label) =>
Object.keys(label)
.map((lang) => {
const text = label[lang].trim().toLowerCase();
return text && `${lang}:${text}`;
})
.filter((text) => text)
)
.flat();
const uniqueLabels = new Set(flattenedLabels);
return uniqueLabels.size !== flattenedLabels.length;
};
const handleI18nCheckForMatrixLabels = (
question: TSurveyMatrixQuestion,
languages: TSurveyLanguage[]
): boolean => {
const rowsAndColumns = [...question.rows, ...question.columns];
const invalidRowsLangCodes = findLanguageCodesForDuplicateLabels(question.rows, languages);
const invalidColumnsLangCodes = findLanguageCodesForDuplicateLabels(question.columns, languages);
if (invalidRowsLangCodes.length > 0 || invalidColumnsLangCodes.length > 0) {
if (hasDuplicates(question.rows)) {
return false;
}
if (hasDuplicates(question.columns)) {
return false;
}
return rowsAndColumns.every((label) => isLabelValidForAllLanguages(label, languages));
};
@@ -184,13 +192,281 @@ export const isCardValid = (
);
};
export const isSurveyValid = (survey: TSurvey, selectedLanguageCode: string) => {
export const isValidUrl = (string: string): boolean => {
try {
new URL(string);
return true;
} catch (e) {
return false;
}
};
// Function to validate question ID and Hidden field Id
export const validateId = (
type: "Hidden field" | "Question",
field: string,
existingQuestionIds: string[],
existingHiddenFieldIds: string[]
): boolean => {
if (field.trim() === "") {
toast.error(`Please enter a ${type} Id.`);
return false;
}
const combinedIds = [...existingQuestionIds, ...existingHiddenFieldIds];
if (combinedIds.findIndex((id) => id.toLowerCase() === field.toLowerCase()) !== -1) {
toast.error(`${type} Id already exists in questions or hidden fields.`);
return false;
}
const forbiddenIds = [
"userId",
"source",
"suid",
"end",
"start",
"welcomeCard",
"hidden",
"verifiedEmail",
"multiLanguage",
"embed",
];
if (forbiddenIds.includes(field)) {
toast.error(`${type} Id not allowed.`);
return false;
}
if (field.includes(" ")) {
toast.error(`${type} Id not allowed, avoid using spaces.`);
return false;
}
if (!/^[a-zA-Z0-9_-]+$/.test(field)) {
toast.error(`${type} Id not allowed, use only alphanumeric characters, hyphens, or underscores.`);
return false;
}
return true;
};
// Checks if there is a cycle present in the survey data logic and returns all questions responsible for the cycle.
export const findQuestionsWithCyclicLogic = (questions: TSurveyQuestions): string[] => {
const visited: Record<string, boolean> = {};
const recStack: Record<string, boolean> = {};
const cyclicQuestions: Set<string> = new Set();
const checkForCyclicLogic = (questionId: string): boolean => {
if (!visited[questionId]) {
visited[questionId] = true;
recStack[questionId] = true;
const question = questions.find((question) => question.id === questionId);
if (question && question.logic && question.logic.length > 0) {
for (const logic of question.logic) {
const destination = logic.destination;
if (!destination) {
continue;
}
if (!visited[destination] && checkForCyclicLogic(destination)) {
cyclicQuestions.add(questionId);
return true;
} else if (recStack[destination]) {
cyclicQuestions.add(questionId);
return true;
}
}
} else {
// Handle default behavior
const nextQuestionIndex = questions.findIndex((question) => question.id === questionId) + 1;
const nextQuestion = questions[nextQuestionIndex];
if (nextQuestion && !visited[nextQuestion.id] && checkForCyclicLogic(nextQuestion.id)) {
return true;
}
}
}
recStack[questionId] = false;
return false;
};
for (const question of questions) {
const questionId = question.id;
checkForCyclicLogic(questionId);
}
return Array.from(cyclicQuestions);
};
export const isSurveyValid = (
survey: TSurvey,
faultyQuestions: string[],
setInvalidQuestions: (questions: string[]) => void,
selectedLanguageCode: string,
setSelectedLanguageCode: (languageCode: string) => void
) => {
const existingQuestionIds = new Set();
// Ensuring at least one question is added to the survey.
if (survey.questions.length === 0) {
toast.error("Please add at least one question");
return false;
}
// Checking the validity of the welcome and thank-you cards if they are enabled.
if (survey.welcomeCard.enabled) {
if (!isCardValid(survey.welcomeCard, "start", survey.languages)) {
faultyQuestions.push("start");
}
}
if (survey.thankYouCard.enabled) {
if (!isCardValid(survey.thankYouCard, "end", survey.languages)) {
faultyQuestions.push("end");
}
}
// Verifying that any provided PIN is exactly four digits long.
const pin = survey.pin;
if (pin && pin.toString().length !== 4) {
toast.error("PIN must be a four digit number.");
return false;
}
// Assessing each question for completeness and correctness,
for (let index = 0; index < survey.questions.length; index++) {
const question = survey.questions[index];
const isFirstQuestion = index === 0;
const isValid = validateQuestion(question, survey.languages, isFirstQuestion);
if (!isValid) {
faultyQuestions.push(question.id);
}
}
// if there are any faulty questions, the user won't be allowed to save the survey
if (faultyQuestions.length > 0) {
setInvalidQuestions(faultyQuestions);
setSelectedLanguageCode("default");
toast.error("Please check for empty fields or duplicate labels");
return false;
}
for (const question of survey.questions) {
const existingLogicConditions = new Set();
if (existingQuestionIds.has(question.id)) {
toast.error("There are 2 identical question IDs. Please update one.");
return false;
}
existingQuestionIds.add(question.id);
if (
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
) {
const haveSameChoices =
question.choices.some((element) => element.label[selectedLanguageCode]?.trim() === "") ||
question.choices.some((element, index) =>
question.choices
.slice(index + 1)
.some(
(nextElement) =>
nextElement.label[selectedLanguageCode]?.trim() === element.label[selectedLanguageCode].trim()
)
);
if (haveSameChoices) {
toast.error("You have empty or duplicate choices.");
return false;
}
}
for (const logic of question.logic || []) {
const validFields = ["condition", "destination", "value"].filter(
(field) => logic[field] !== undefined
).length;
if (validFields < 2) {
setInvalidQuestions([question.id]);
toast.error("Incomplete logic jumps detected: Fill or remove them in the Questions tab.");
return false;
}
if (question.required && logic.condition === "skipped") {
toast.error("A logic condition is missing: Please update or delete it in the Questions tab.");
return false;
}
const thisLogic = `${logic.condition}-${logic.value}`;
if (existingLogicConditions.has(thisLogic)) {
setInvalidQuestions([question.id]);
toast.error(
"There are two competing logic conditons: Please update or delete one in the Questions tab."
);
return false;
}
existingLogicConditions.add(thisLogic);
}
}
// Checking the validity of redirection URLs to ensure they are properly formatted.
if (
survey.redirectUrl &&
!survey.redirectUrl.includes("https://") &&
!survey.redirectUrl.includes("http://")
) {
toast.error("Please enter a valid URL for redirecting respondents.");
return false;
}
// validate the user segment filters
const localSurveySegment = {
id: survey.segment?.id,
filters: survey.segment?.filters,
title: survey.segment?.title,
description: survey.segment?.description,
};
const surveySegment = {
id: survey.segment?.id,
filters: survey.segment?.filters,
title: survey.segment?.title,
description: survey.segment?.description,
};
// if the non-private segment in the survey and the strippedSurvey are different, don't save
if (!survey.segment?.isPrivate && !isEqual(localSurveySegment, surveySegment)) {
toast.error("Please save the audience filters before saving the survey");
return false;
}
if (!!survey.segment?.filters?.length) {
const parsedFilters = ZSegmentFilters.safeParse(survey.segment.filters);
if (!parsedFilters.success) {
const errMsg =
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message ||
"Invalid targeting: Please check your audience filters";
toast.error(errMsg);
return false;
}
}
const questionWithEmptyFallback = checkForEmptyFallBackValue(survey, selectedLanguageCode);
if (questionWithEmptyFallback) {
toast.error("Fallback missing");
return false;
}
// Detecting any cyclic dependencies in survey logic.
const questionsWithCyclicLogic = findQuestionsWithCyclicLogic(survey.questions);
if (questionsWithCyclicLogic.length > 0) {
setInvalidQuestions(questionsWithCyclicLogic);
toast.error("Cyclic logic detected. Please fix it before saving.");
return false;
}
if (survey.type === "app" && survey.segment?.id === "temp") {
const { filters } = survey.segment;

View File

@@ -1,4 +1,4 @@
import { TSurvey } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys";
export const minimalSurvey: TSurvey = {
id: "someUniqueId1",

View File

@@ -4,7 +4,7 @@ import { getServerSession } from "next-auth";
import { canUserAccessAttributeClass } from "@formbricks/lib/attributeClass/auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { getSegmentsByAttributeClassName } from "@formbricks/lib/segment/service";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { AuthorizationError } from "@formbricks/types/errors";
export const getSegmentsByAttributeClassAction = async (

View File

@@ -4,7 +4,7 @@ import { TagIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { convertDateTimeStringShort } from "@formbricks/lib/time";
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
import { Label } from "@formbricks/ui/Label";
import { LoadingSpinner } from "@formbricks/ui/LoadingSpinner";

View File

@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { Switch } from "@formbricks/ui/Switch";
import { AttributeDetailModal } from "./AttributeDetailModal";
import { AttributeClassDataRow } from "./AttributeRowData";

View File

@@ -1,5 +1,5 @@
import { TagIcon } from "lucide-react";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { ModalWithTabs } from "@formbricks/ui/ModalWithTabs";
import { AttributeActivityTab } from "./AttributeActivityTab";
import { AttributeSettingsTab } from "./AttributeSettingsTab";

View File

@@ -1,23 +1,17 @@
"use server";
import { z } from "zod";
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
import { getOrganizationIdFromPersonId } from "@formbricks/lib/organization/utils";
import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { canUserAccessPerson } from "@formbricks/lib/person/auth";
import { deletePerson } from "@formbricks/lib/person/service";
import { AuthorizationError } from "@formbricks/types/errors";
const ZPersonDeleteAction = z.object({
personId: z.string(),
});
export const deletePersonAction = async (personId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
export const deletePersonAction = authenticatedActionClient
.schema(ZPersonDeleteAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromPersonId(parsedInput.personId),
rules: ["person", "delete"],
});
const isAuthorized = await canUserAccessPerson(session.user.id, personId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await deletePerson(parsedInput.personId);
});
await deletePerson(personId);
};

View File

@@ -0,0 +1,61 @@
import { formatDistance } from "date-fns";
import { CodeIcon, MousePointerClickIcon, SparklesIcon } from "lucide-react";
import { TAction } from "@formbricks/types/actions";
import { Label } from "@formbricks/ui/Label";
import { Popover, PopoverContent, PopoverTrigger } from "@formbricks/ui/Popover";
export const ActivityItemIcon = ({ actionItem }: { actionItem: TAction }) => (
<div className="h-12 w-12 rounded-full bg-white p-3 text-slate-500 duration-100 ease-in-out group-hover:scale-110 group-hover:text-slate-600">
<div>
{actionItem.actionClass?.type === "code" && <CodeIcon className="h-5 w-5" />}
{actionItem.actionClass?.type === "noCode" && <MousePointerClickIcon className="h-5 w-5" />}
{actionItem.actionClass?.type === "automatic" && <SparklesIcon className="h-5 w-5" />}
</div>
</div>
);
export const ActivityItemContent = ({ actionItem }: { actionItem: TAction }) => (
<div>
<div className="font-semibold text-slate-700">
{actionItem.actionClass ? <p>{actionItem.actionClass.name}</p> : <p>Unknown Activity</p>}
</div>
<div className="text-sm text-slate-400">
<time
dateTime={formatDistance(actionItem.createdAt, new Date(), {
addSuffix: true,
})}>
{formatDistance(actionItem.createdAt, new Date(), {
addSuffix: true,
})}
</time>
</div>
</div>
);
export const ActivityItemPopover = ({
actionItem,
children,
}: {
actionItem: TAction;
children: React.ReactNode;
}) => {
return (
<Popover>
<PopoverTrigger className="group">{children}</PopoverTrigger>
<PopoverContent className="bg-white">
<div>
{actionItem && (
<div>
<Label className="font-normal text-slate-400">Action Label</Label>
<p className="mb-2 text-sm font-medium text-slate-900">{actionItem.actionClass!.name}</p>
<Label className="font-normal text-slate-400">Action Description</Label>
<p className="text-sm font-medium text-slate-900">{actionItem.actionClass!.description}</p>
<Label className="font-normal text-slate-400">Action Type</Label>
<p className="text-sm font-medium text-slate-900">{actionItem.actionClass!.type}</p>
</div>
)}
</div>
</PopoverContent>
</Popover>
);
};

View File

@@ -0,0 +1,44 @@
import { ActivityTimeline } from "@/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/ActivityTimeline";
import { getAdvancedTargetingPermission } from "@formbricks/ee/lib/service";
import { getActionsByPersonId } from "@formbricks/lib/action/service";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
export const ActivitySection = async ({
environmentId,
personId,
}: {
environmentId: string;
personId: string;
}) => {
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new Error("Organization not found");
}
// On Formbricks Cloud only render the timeline if the user targeting feature is booked
const isUserTargetingEnabled = IS_FORMBRICKS_CLOUD
? await getAdvancedTargetingPermission(organization)
: true;
const [environment, actions] = await Promise.all([
getEnvironment(environmentId),
isUserTargetingEnabled ? getActionsByPersonId(personId, 1) : [],
]);
if (!environment) {
throw new Error("Environment not found");
}
return (
<div className="md:col-span-1">
<ActivityTimeline
environment={environment}
actions={actions.slice(0, 10)}
isUserTargetingEnabled={isUserTargetingEnabled}
/>
</div>
);
};

View File

@@ -0,0 +1,70 @@
import { TAction } from "@formbricks/types/actions";
import { TEnvironment } from "@formbricks/types/environment";
import { EmptySpaceFiller } from "@formbricks/ui/EmptySpaceFiller";
import { UpgradePlanNotice } from "@formbricks/ui/UpgradePlanNotice";
import { ActivityItemContent, ActivityItemIcon, ActivityItemPopover } from "./ActivityItemComponents";
interface IActivityTimelineProps {
environment: TEnvironment;
actions: TAction[];
isUserTargetingEnabled: boolean;
}
export const ActivityTimeline = ({
environment,
actions,
isUserTargetingEnabled,
}: IActivityTimelineProps) => {
return (
<>
<div className="flex items-center justify-between pb-6">
<h2 className="text-lg font-bold text-slate-700">Actions Timeline</h2>
</div>
{!isUserTargetingEnabled ? (
<UpgradePlanNotice
message="Upgrade your plan to store action history."
textForUrl="More info."
url={`/environments/${environment.id}/settings/billing`}
/>
) : (
<div className="relative">
{actions.length === 0 ? (
<EmptySpaceFiller type={"event"} environment={environment} />
) : (
<div>
{actions.map(
(actionItem, index) =>
actionItem && (
<li key={actionItem.id} className="list-none">
<div className="relative pb-12">
{index !== actions.length - 1 && (
<span
className="absolute left-6 top-4 -ml-px h-full w-0.5 bg-slate-200"
aria-hidden="true"
/>
)}
<div className="relative">
<ActivityItemPopover actionItem={actionItem}>
<div className="flex space-x-3 text-left">
<ActivityItemIcon actionItem={actionItem} />
<ActivityItemContent actionItem={actionItem} />
</div>
</ActivityItemPopover>
</div>
</div>
</li>
)
)}
<div className="relative">
{actions.length === 10 && (
<div className="absolute bottom-0 flex h-56 w-full items-end justify-center bg-gradient-to-t from-slate-50 to-transparent"></div>
)}
</div>
</div>
)}
</div>
)}
</>
);
};

View File

@@ -5,7 +5,6 @@ import { TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
interface DeletePersonButtonProps {
@@ -23,21 +22,14 @@ export const DeletePersonButton = ({ environmentId, personId, isViewer }: Delete
const handleDeletePerson = async () => {
try {
setIsDeletingPerson(true);
const deletePersonResponse = await deletePersonAction({ personId });
if (deletePersonResponse?.data) {
router.refresh();
router.push(`/environments/${environmentId}/people`);
toast.success("Person deleted successfully.");
} else {
const errorMessage = getFormattedErrorMessage(deletePersonResponse);
toast.error(errorMessage);
}
await deletePersonAction(personId);
router.refresh();
router.push(`/environments/${environmentId}/people`);
toast.success("Person deleted successfully.");
} catch (error) {
toast.error(error.message);
} finally {
setIsDeletingPerson(false);
setDeleteDialogOpen(false);
}
};

View File

@@ -3,9 +3,9 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { getResponsesByPersonId } from "@formbricks/lib/response/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys";
import { TTag } from "@formbricks/types/tags";
interface ResponseSectionProps {

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