Compare commits
5 Commits
v2.3.2
...
aschaber-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4327319d75 | ||
|
|
dc4f146983 | ||
|
|
0ed6401df7 | ||
|
|
e856006e04 | ||
|
|
01210dba3f |
14
.env.example
@@ -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 -----------------------------#
|
||||
########################################################################
|
||||
|
||||
6
.github/actions/cache-build-web/action.yml
vendored
@@ -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
|
||||
|
||||
24
.github/workflows/build-web.yml
vendored
@@ -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...
|
||||
|
||||
2
.github/workflows/e2e.yml
vendored
@@ -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
@@ -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
@@ -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
|
||||
6
.github/workflows/lint.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
3
.kamal/hooks/post-traefik-reboot.sample
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo "Rebooted Traefik on $KAMAL_HOSTS"
|
||||
51
.kamal/hooks/pre-build.sample
Executable 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
@@ -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
@@ -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
|
||||
3
.kamal/hooks/pre-traefik-reboot.sample
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo "Rebooting Traefik on $KAMAL_HOSTS..."
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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&controls=0" />
|
||||
|
||||
<ResponsiveVideo
|
||||
title="Formbricks Multi-language Surveys"
|
||||
src="https://www.youtube-nocookie.com/embed/0BQp6N4cXzU?si=KeBM7G7Ch1xtrsOm&controls=0"
|
||||
/>
|
||||
|
||||
## How to setup Advanced Targeting
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
|
Before Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 ✅
|
||||
|
||||
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 43 KiB |
@@ -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.
|
||||
|
||||
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 12 KiB |
@@ -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.
|
||||
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 39 KiB |
@@ -12,56 +12,55 @@ export const metadata = {
|
||||
|
||||
These variables are present inside your machine’s 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 we’ll try our best to work out a solution with you.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
export const minimalSurvey: TSurvey = {
|
||||
id: "someUniqueId1",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||