Compare commits

..

81 Commits

Author SHA1 Message Date
Matti Nannt
7960aaf5d5 fix: mls labels not set properly on question create (#2979) 2024-08-07 23:21:59 +02:00
Matti Nannt
32b3a7d1d0 fix: add data-migration fixing invalid jump end jump destination (#2972) 2024-08-07 16:57:13 +02:00
Piyush Gupta
53fb976fb6 feat: adds tls1.2 and 1.3 support, and HSTS config (#2897)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-08-07 16:52:14 +02:00
Johannes
fffe71aa7e fix: add products with channel null to list (#2976) 2024-08-07 11:45:48 +00:00
Anshuman Pandey
75b0a3a407 fix: rename surveys to surveyTriggers (#2975) 2024-08-07 10:36:37 +00:00
Adam Gay
158689672a fix: Entra ID / Azure AD docs formatting and images (#2973)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-08-07 12:46:36 +02:00
Dhruwang Jariwala
24e43dd1a2 fix: filtering issue (#2971) 2024-08-07 09:15:18 +00:00
Matthias Nannt
b34366aaf7 chore: increase formbricks version to 2.4.1 2024-08-06 14:46:20 +02:00
Matti Nannt
2856c8d125 fix: survey validation failing with custom questionId logic (#2969) 2024-08-06 11:18:01 +02:00
Adam Gay
4ac1e1d798 docs: update and improve Microsoft Entra ID / Azure AD app registration steps (#2965)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2024-08-06 08:44:15 +00:00
Anshuman Pandey
b15d23035c fix: segment delete fix (#2967) 2024-08-06 08:38:05 +00:00
Johannes
8b2ea63ccb chore: tweaking product toggle (#2958) 2024-08-06 08:30:29 +00:00
Matti Nannt
b0c65c76e6 chore: add environment created posthog event (#2966) 2024-08-05 20:51:25 +02:00
Smriti Doneria
c65c1af023 feat: add ability to copy surveys between different environments of different products (#2832)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2024-08-05 15:04:58 +00:00
Matti Nannt
f10bd9c0d8 chore: improve posthog tracking with survey created event (#2964) 2024-08-05 17:08:36 +02:00
mdm317
a6ac78294b fix: demo app responsive add favicon (#2960)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2024-08-05 15:36:33 +02:00
Jonas Höbenreich
04c9ead19d fix: fix typos (#2963) 2024-08-05 12:47:25 +00:00
Dhruwang Jariwala
3c3798ee98 feat: Apply filters from question summary (#2940)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2024-08-05 09:24:01 +00:00
Matthias Nannt
8df722ab02 fix: data migration docker image doesnt always get latest tag 2024-08-02 22:05:11 +02:00
Matthias Nannt
dbe5ca60cd fix: endings migration type error 2024-08-02 21:51:42 +02:00
Ratnadeep
ec454dc981 fix: Images are sorted wrong in dashboard #2943 (#2957)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2024-08-02 21:08:32 +02:00
Matti Nannt
0988f2145c chore: prepare 2.4.0 release (#2959) 2024-08-02 20:32:21 +02:00
Sachin Mittal
3416c26bdc fix: color coding in NPS misplaces text (#2953)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-08-02 12:19:38 +02:00
Johannes
f1a50b7db3 fix: increase image size for embed mode (#2954) 2024-08-02 12:19:15 +02:00
Dhruwang Jariwala
f2fa13ba01 chore: Simplify email verification (#2926)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-08-02 09:44:38 +00:00
Sachin Mittal
93fc3bf39e fix: identation for docker-compose.yml (#2949) 2024-08-02 09:31:47 +00:00
Anshuman Pandey
695180a2ef fix: survey create input schema validations (#2939) 2024-08-02 08:57:27 +00:00
use-tusk[bot]
aeed138294 feat: Add Modal component story for Storybook (#2952)
Co-authored-by: use-tusk[bot] <144006087+use-tusk[bot]@users.noreply.github.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-08-02 07:46:02 +00:00
Dhruwang Jariwala
5d347096cf feat: multiple end screens (#2863)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-08-02 04:48:41 +00:00
Matti Nannt
75ade97805 chore: remove client APIs older than v1.6.5 (#2945) 2024-08-01 17:31:01 +02:00
Dhruwang Jariwala
864d4b3cb7 fix: calHost UX and validation in cal question (#2944) 2024-08-01 14:27:14 +00:00
Anshuman Pandey
c1492e3429 fix: session token size (#2941)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-08-01 12:27:54 +00:00
Matti Nannt
ae266810c2 chore: improve button a11y with storybook (#2938) 2024-08-01 14:15:03 +02:00
Dhruwang Jariwala
a1d3fe5e06 feat: Extended logic jumps for picture selection question (#2932)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-08-01 03:32:00 +00:00
Johannes
98886ff074 chore: Add create product link in survey editor (#2937) 2024-07-31 14:23:25 +00:00
Anshuman Pandey
b8b7a374a6 fix: action update cache (#2936) 2024-07-31 11:18:33 +00:00
Dhruwang Jariwala
9d3647f38a chore: migrate db package to new linting rules (#2887) 2024-07-31 11:11:09 +00:00
Matthias Nannt
5332eec7aa fix: chromatic storybook action 2024-07-31 12:50:39 +02:00
Matthias Nannt
6bc933532e chore: add chromatic github action to publish storybook 2024-07-31 12:48:01 +02:00
Saurabh Chaddha
55053cd2b8 feat: add LoadingSpinner component to storybook component (#2913)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-07-31 10:29:23 +00:00
Anshuman Pandey
2e4317a80c fix: surveys with no segment fixes (#2903)
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
2024-07-31 10:04:56 +00:00
Piyush Gupta
5ef61f4621 fix: Dissmissed responses are (but should not be) counted in the summary graph (#2935) 2024-07-31 10:04:11 +00:00
Matthias Nannt
4b47a5030a fix: pageHeader story references deprecated button 2024-07-31 12:08:48 +02:00
Vaishak K
27c2dcbee4 fix: close tag input immediately on keydown and add tag button click (#2911) 2024-07-31 11:16:16 +02:00
Vaishak K
e17ab16878 fix: add delete confirmation modal before deleting tags (#2912)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2024-07-31 11:15:44 +02:00
Matti Nannt
8c9fff36c8 chore: send setup completed event to posthog (#2933) 2024-07-31 10:36:25 +02:00
TUSHAR JINDAL
c37ea3fbb6 feat: add storybook for PageHeader component (#2906)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2024-07-31 08:13:45 +00:00
TUSHAR JINDAL
6d6401d1da feat: add storybook for Label component (#2907)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2024-07-31 08:01:50 +00:00
Smriti Doneria
57f90c8b3b feat: add badge component to Storybook (#2899)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-07-31 07:59:25 +00:00
Matti Nannt
53883d290a chore: remove v2 button from ui (#2930) 2024-07-31 09:58:00 +02:00
TUSHAR JINDAL
ae0408a6f7 feat: add storybook for card component (#2900)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2024-07-30 14:23:51 +00:00
TUSHAR JINDAL
742abc9032 feat: add storybook for Input component (#2904)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2024-07-30 14:18:27 +00:00
Matti Nannt
5652965853 fix: remove deprecated actions from segments all list (#2929) 2024-07-30 15:21:14 +02:00
Rakshit Bhardwaj
da0c811846 style: Use AdvancedOptionToggle in schedule a call form (#2925)
Signed-off-by: Rakshit Bhardwaj <157364563+Acksout@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2024-07-30 11:02:30 +00:00
Johannes
d60dd5f281 chore: Streamline Onboarding (#2928) 2024-07-30 10:47:31 +00:00
Dhruwang Jariwala
ec70c6c613 fix: recall from hidden fields of in-app surveys (#2917) 2024-07-29 10:30:13 +00:00
Matthias Nannt
aa40b916ab chore: remove legacy unlimited prices 2024-07-29 10:19:32 +02:00
fetsorn
fe5242174b fix: typo in onboarding (#2914)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2024-07-27 09:23:58 +00:00
Laurens Nienhaus
026cc29e1f docs: unify CRON_SECRET descriptions (#2915)
Co-authored-by: laurens <laurens@users.noreply.github.com>
2024-07-27 09:11:05 +00:00
Dhruwang Jariwala
6055baa0db fix: recall in welcome card (#2918) 2024-07-27 09:06:17 +00:00
Matthias Nannt
469e6da29f fix: typo in test mode banner 2024-07-24 08:14:55 +02:00
Matthias Nannt
6a7fb2d33d chore: bump Formbricks version to 2.3.2 2024-07-17 11:53:59 +02:00
Matti Nannt
271ea89c8d fix: reintroduce NEXTAUTH_URL to fix logout redirect (#2905) 2024-07-17 11:08:10 +02:00
Dhruwang Jariwala
f94d7f2b03 fix: 404 on js integration connect page (#2901) 2024-07-17 06:41:08 +00:00
Dhruwang Jariwala
f084e64aed chore: add hidden field to response finished email (#2872) 2024-07-16 10:12:23 +00:00
Dhruwang Jariwala
294b817957 chore: added SMTP_REJECT_UNAUTHORIZED_TLS env variable (#2876)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-07-16 09:31:58 +00:00
Dhruwang Jariwala
ed85ed394a fix: Added type to ResetProgressButton (#2889) 2024-07-16 06:52:26 +00:00
Dhruwang Jariwala
aa495312db fix: notion integration (#2888) 2024-07-15 14:29:42 +00:00
Smriti Doneria
8ac9b23de3 fix: restart functionality (#2885)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2024-07-15 10:33:52 +00:00
Matthias Nannt
be7090b29c chore: improve error logging in pipeline 2024-07-15 12:21:06 +02:00
Matthias Nannt
1f9b31a6cd chore: remove kamal configs 2024-07-12 20:30:43 +02:00
Anshuman Pandey
e83d27f07c fix: CTA button URL fix (#2881) 2024-07-12 15:22:33 +00:00
Matti Nannt
816cbd2036 chore: simplify person detail page by removing activity timeline (#2878) 2024-07-11 18:42:00 +02:00
Anshuman Pandey
ec781969fa fix: survey validation thank you card (#2875)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-07-11 15:22:37 +00:00
Matti Nannt
2fc78c9219 chore: deprecate action filtering in advanced targeting (#2877) 2024-07-11 13:57:03 +02:00
Matti Nannt
7b2470cce6 chore: only store actions on the server when enabled in environment (#2871) 2024-07-10 16:40:52 +02:00
Anshuman Pandey
31c3fac7f5 fix: js package linting (#2868)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-07-10 12:28:27 +00:00
Piyush Gupta
9d5a7b7dbd feat: adds vercel style guide eslint rules to types package (#2869) 2024-07-10 09:58:49 +00:00
Piyush Gupta
614710da69 chore: update local setup links in README.md (#2866)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2024-07-10 12:09:22 +02:00
Dhruwang Jariwala
f233066e81 fix: airtable UX (#2865) 2024-07-10 09:53:22 +00:00
Matti Nannt
32ae38ebb2 chore: add react cache to improve render performance (#2867) 2024-07-10 11:43:59 +02:00
505 changed files with 11735 additions and 13557 deletions

View File

@@ -8,6 +8,9 @@
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=
@@ -31,7 +34,7 @@ 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)
# API Secret for running cron jobs. (mandatory)
# You can use: `openssl rand -hex 32` to generate a secure one
CRON_SECRET=RANDOM_STRING
@@ -50,6 +53,9 @@ 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 -----------------------------#
########################################################################

30
.github/workflows/chromatic.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: "Chromatic"
on:
push:
branches:
- main
workflow_dispatch:
jobs:
chromatic:
name: Run Chromatic
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Run Chromatic
uses: chromaui/action@latest
with:
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
workingDir: apps/storybook

View File

@@ -1,129 +0,0 @@
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 }}
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

View File

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

View File

@@ -11,10 +11,10 @@ jobs:
- uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 18.x
- name: Setup Node.js 20.x
uses: actions/setup-node@v3
with:
node-version: 18.x
node-version: 20.x
- name: Install pnpm
uses: pnpm/action-setup@v4

View File

@@ -1,10 +1,5 @@
name: Docker for Data Migrations
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
on:
workflow_dispatch:
push:
@@ -12,7 +7,6 @@ on:
- "v*"
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
IMAGE_NAME: formbricks/data-migrations
DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/formbricks?schema=public"
@@ -23,8 +17,6 @@ jobs:
permissions:
contents: read
packages: write
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write
steps:
@@ -50,6 +42,7 @@ jobs:
tags: |
type=ref,event=tag
type=raw,value=${{ github.ref_name }}
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v3
@@ -66,3 +59,4 @@ jobs:
if: ${{ github.event_name != 'pull_request' }}
run: |
cosign sign --yes ghcr.io/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
cosign sign --yes ghcr.io/${{ env.IMAGE_NAME }}:latest

2
.gitignore vendored
View File

@@ -56,5 +56,5 @@ Zone.Identifier
packages/lib/uploads
# Vite Timestamps
vite.config.*.timestamp-*
*vite.config.*.timestamp-*

View File

@@ -1,4 +1 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint-staged

View File

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

View File

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

View File

@@ -1,51 +0,0 @@
#!/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

View File

@@ -1,47 +0,0 @@
#!/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 ]

View File

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

View File

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

View File

@@ -160,7 +160,7 @@ Here is what you need to be able to run Formbricks:
### Local Setup
To get started locally, we've got a [guide to help you](https://formbricks.com/docs/contributing/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).
### 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/contributing/introduction) 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/developer-docs/contributing/get-started) and our [list of open issues](https://github.com/formbricks/formbricks/issues) for more information.
## All Thanks To Our Contributors

View File

@@ -13,8 +13,8 @@
"dependencies": {
"@formbricks/js": "workspace:*",
"@formbricks/ui": "workspace:*",
"lucide-react": "^0.397.0",
"next": "14.2.4",
"lucide-react": "^0.418.0",
"next": "14.2.5",
"react": "18.3.1",
"react-dom": "18.3.1"
},

View File

@@ -62,7 +62,7 @@ const AppPage = ({}) => {
return (
<div className="h-screen bg-white px-12 py-6 dark:bg-slate-800">
<div className="flex flex-col justify-between md:flex-row">
<div className="flex items-center gap-2">
<div className="flex flex-col items-center gap-2 sm:flex-row">
<SurveySwitch value="app" formbricks={formbricks} />
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">

View File

@@ -58,7 +58,7 @@ const AppPage = ({}) => {
return (
<div className="h-screen bg-white px-12 py-6 dark:bg-slate-800">
<div className="flex flex-col items-center justify-between md:flex-row">
<div className="flex items-center gap-2">
<div className="flex flex-col items-center gap-2 sm:flex-row">
<SurveySwitch value="website" formbricks={formbricks} />
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -17,11 +17,17 @@ export const metadata = {
# Advanced Targeting
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.
<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>
<ResponsiveVideo title="Formbricks Multi-language Surveys"
src="https://www.youtube-nocookie.com/embed/0BQp6N4cXzU?si=KeBM7G7Ch1xtrsOm&amp;controls=0" />
Advanced Targeting allows you to show surveys to the right group of people. You can target surveys based on user attributes, device type, and more instead of spraying and praying. This helps you get more relevant feedback and make data-driven decisions. All of this without writing a single line of code.
<ResponsiveVideo
title="Formbricks Multi-language Surveys"
src="https://www.youtube-nocookie.com/embed/0BQp6N4cXzU?si=KeBM7G7Ch1xtrsOm&amp;controls=0"
/>
## How to setup Advanced Targeting

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,3 +1,16 @@
import { MdxImage } from "@/components/MdxImage";
import EntraIDAppReg01 from "./images/entra_app_reg_01.png";
import EntraIDAppReg02 from "./images/entra_app_reg_02.png";
import EntraIDAppReg03 from "./images/entra_app_reg_03.png";
import EntraIDAppReg04 from "./images/entra_app_reg_04.png";
import EntraIDAppReg05 from "./images/entra_app_reg_05.png";
import EntraIDAppReg06 from "./images/entra_app_reg_06.png";
import EntraIDAppReg07 from "./images/entra_app_reg_07.png";
import EntraIDAppReg08 from "./images/entra_app_reg_08.png";
import EntraIDAppReg09 from "./images/entra_app_reg_09.png";
import EntraIDAppReg10 from "./images/entra_app_reg_10.png";
export const metadata = {
title: "Configure Formbricks with External auth providers",
description:
@@ -12,54 +25,56 @@ export const metadata = {
These variables are present inside your machines docker-compose file. Restart the docker containers if you change any variables for them to take effect.
| Variable | Description | Required | Default |
| --------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------- | ------------------------- |
| WEBAPP_URL | Base URL of the site. | required | http://localhost:3000 |
| 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) | |
| 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 |
| 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>` | | | |
| | | | |
Note: If you want to configure something that is not possible via above, please open an issue on our GitHub repo here or reach out to us on Discord and well try our best to work out a solution with you.
@@ -133,26 +148,141 @@ GOOGLE_CLIENT_SECRET=your-client-secret-here
- Navigate to your Docker setup directory where your `docker-compose.yml` file is located.
- Run the following command to bring down your current Docker containers and then bring them back up with the updated environment configuration:
### Azure SSO OAuth
### Microsoft Entra ID (Azure Active Directory) SSO OAuth
Have an Azure Active Directory (AAD) instance? Integrate it with your Formbricks instance to allow users to log in using their existing AAD credentials. This guide will walk you through the process of setting up Azure SSO for your Formbricks instance.
Do you have a Microsoft Entra ID Tenant? Integrate it with your Formbricks instance to allow users to log in using their existing Microsoft credentials. This guide will walk you through the process of setting up an Application Registration for your Formbricks instance.
### Requirements
#### Requirements
- An Azure Active Directory (AAD) instance.
- A Microsoft Entra ID Tenant populated with users. [Create a tenant as per Microsoft's documentation](https://learn.microsoft.com/en-us/entra/fundamentals/create-new-tenant).
- A Formbricks instance running and accessible.
- The callback URI for your Formbricks instance: `{WEBAPP_URL}/api/auth/callback/azure-ad`
### Steps
#### Creating an App Registration
1. Create a new Tenant in Azure Active Directory as per their [official documentation](https://learn.microsoft.com/en-us/entra/fundamentals/create-new-tenant).
2. Add Users & Groups to your AAD instance.
3. Now we need to fill the below environment variables in our Formbricks instance so get them from your AD configuration:
- `AZUREAD_CLIENT_ID`
- `AZUREAD_CLIENT_SECRET`
- `AZUREAD_TENANT_ID`
4. Update these environment variables in your `docker-compose.yml` or pass it like your other environment variables to the Formbricks container.
5. Restart your Formbricks instance.
6. You're all set! Users can now signup & log in using their AAD credentials.
1. Login to the [Microsoft Entra admin center](https://entra.microsoft.com/).
2. Go to **Applications** > **App registrations** in the left menu.
<MdxImage
src={EntraIDAppReg01}
alt="App Registration Name Field"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
3. Click the **New registration** button at the top.
<MdxImage
src={EntraIDAppReg02}
alt="App Registration Name Field"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
4. Name your application something descriptive, such as `Formbricks SSO`.
<MdxImage
src={EntraIDAppReg03}
alt="App Registration Name Field"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
5. If you have multiple tenants/organizations, choose the appropriate **Supported account types** option. Otherwise, leave the default option for _Single Tenant_.
<MdxImage
src={EntraIDAppReg04}
alt="Supported Account Types List"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
6. Under **Redirect URI**, select **Web** for the platform and paste your Formbricks callback URI (see Requirements above).
<MdxImage
src={EntraIDAppReg05}
alt="Redirect URI Field"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
7. Click **Register** to create the App registration. You will be redirected to your new app's _Overview_ page after it is created.
8. On the _Overview_ page, under **Essentials**:
- Copy the entry for **Application (client) ID** to populate the `AZUREAD_CLIENT_ID` variable.
- Copy the entry for **Directory (tenant) ID** to populate the `AZUREAD_TENANT_ID` variable.
<MdxImage
src={EntraIDAppReg06}
alt="Client and Tenant ID Fields"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
9. From your App registration's _Overview_ page, go to **Manage** > **Certificates & secrets**.
<MdxImage
src={EntraIDAppReg07}
alt="Certificates & secrets link"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
10. Make sure you have the **Client secrets** tab active, and click **New client secret**.
<MdxImage
src={EntraIDAppReg08}
alt="New Client Secret Tab & Button"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
11. Enter a **Description**, set an **Expires** period, then click **Add**.
<Note>
You will need to create a new client secret using these steps whenever your chosen expiry period ends.
</Note>
<MdxImage
src={EntraIDAppReg09}
alt="Description & Expires Fields"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
12. Copy the entry under **Value** to populate the `AZUREAD_CLIENT_SECRET` variable.
<Note>
Microsoft will only show this value to you immediately after creation, and you will not be able to access it again. If you lose it, simply start from step 9 to create a new secret.
</Note>
<MdxImage
src={EntraIDAppReg10}
alt="Client Secret Value Field"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
13. Update these environment variables in your `docker-compose.yml` or pass it like your other environment variables to the Formbricks container.
<Note>
You must wrap the `AZUREAD_CLIENT_SECRET` value in double quotes (e.g., `"THis~iS4faKe.53CreTvALu3"`) to prevent issues with special characters.
</Note>
An example `.env` for Microsoft Entra ID in Formbricks would look like:
<Col>
<CodeGroup title="Formbricks Env for Microsoft Entra ID SSO">
```yml {{ title: ".env" }}
AZUREAD_CLIENT_ID=a25cadbd-f049-4690-ada3-56a163a72f4c
AZUREAD_TENANT_ID=2746c29a-a3a6-4ea1-8762-37816d4b7885
AZUREAD_CLIENT_SECRET="THis~iS4faKe.53CreTvALu3"
```
</CodeGroup>
</Col>
14. Restart your Formbricks instance.
15. You're all set! Users can now sign up & log in using their Microsoft credentials associated with your Entra ID Tenant.
## OpenID Configuration

View File

@@ -8,6 +8,108 @@ export const metadata = {
# Migration Guide
## v2.4
Formbricks v2.4 allows you to create multiple endings for your surveys and decide which ending the user should see based on logic jumps. This release also includes many bug fixes and performance improvements.
<Note>
This release will drop support for advanced targeting (enterprise targeting for app surveys) with actions
(e.g. only target users that triggered action x 3 times in the last month). This means that actions can
still be used as triggers, but will no longer be stored on the server in order to improve the overall
performance of the Formbricks system.
</Note>
### Steps to Migrate
This guide is for users who are self-hosting Formbricks using our one-click setup. If you are using a different setup, you might adjust the commands accordingly.
To run all these steps, please navigate to the `formbricks` folder where your `docker-compose.yml` file is located.
1. **Backup your Database**: This is a crucial step. Please make sure to backup your database before proceeding with the upgrade. You can use the following command to backup your database:
<Col>
<CodeGroup title="Backup Postgres">
```bash
docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbricks_pre_v2.4_$(date +%Y%m%d_%H%M%S).dump
```
</CodeGroup>
</Col>
<Note>
If you run into “No such container”, use `docker ps` to find your container name, e.g.
`formbricks_postgres_1`.
</Note>
<Note>
If you prefer storing the backup as an `*.sql` file remove the `-Fc` (custom format) option. In case of a
restore scenario you will need to use `psql` then with an empty `formbricks` database.
</Note>
2. Pull the latest version of Formbricks:
<Col>
<CodeGroup title="Stop the containers">
```bash
docker compose pull
```
</CodeGroup>
</Col>
3. Stop the running Formbricks instance & remove the related containers:
<Col>
<CodeGroup title="Stop the containers">
```bash
docker compose down
```
</CodeGroup>
</Col>
4. Restarting the containers with the latest version of Formbricks:
<Col>
<CodeGroup title="Restart the containers">
```bash
docker compose up -d
```
</CodeGroup>
</Col>
5. Now let's migrate the data to the latest schema:
<Note>To find your Docker Network name for your Postgres Database, find it using `docker network ls`</Note>
<Col>
<CodeGroup title="Migrate the data">
```bash
docker pull ghcr.io/formbricks/data-migrations:latest && \
docker run --rm \
--network=formbricks_default \
-e DATABASE_URL="postgresql://postgres:postgres@postgres:5432/formbricks?schema=public" \
-e UPGRADE_TO_VERSION="v2.4" \
ghcr.io/formbricks/data-migrations:latest
```
</CodeGroup>
</Col>
The above command will migrate your data to the latest schema. This is a crucial step to migrate your existing data to the new structure. Only if the script runs successful, changes are made to the database. The script can safely run multiple times.
6. That's it! Once the migration is complete, you can **now access your Formbricks instance** at the same URL as before.
### Additional Updates
- The `CRON_SECRET` environment variable is now required to improve the security of the internal cron APIs. Please make sure that the variable is set in your environment / docker-compose.yml. You can use `openssl rand -hex 32` to generate a secure secret.
## v2.3
Formbricks v2.3 includes new color options for rating questions, improved multi-language functionality for Chinese (Simplified & Traditional), and various bug fixes and performance improvements.
@@ -89,12 +191,12 @@ docker compose up -d
<CodeGroup title="Migrate the data">
```bash
docker pull ghcr.io/formbricks/data-migrations:latest && \
docker pull ghcr.io/formbricks/data-migrations:v2.3.0 && \
docker run --rm \
--network=formbricks_default \
-e DATABASE_URL="postgresql://postgres:postgres@postgres:5432/formbricks?schema=public" \
-e UPGRADE_TO_VERSION="v2.3" \
ghcr.io/formbricks/data-migrations:latest
ghcr.io/formbricks/data-migrations:v2.3.0
```
</CodeGroup>
@@ -184,12 +286,12 @@ docker compose up -d
<CodeGroup title="Migrate the data">
```bash
docker pull ghcr.io/formbricks/data-migrations:latest && \
docker pull ghcr.io/formbricks/data-migrations:v2.2 && \
docker run --rm \
--network=formbricks_default \
-e DATABASE_URL="postgresql://postgres:postgres@postgres:5432/formbricks?schema=public" \
-e UPGRADE_TO_VERSION="v2.2" \
ghcr.io/formbricks/data-migrations:latest
ghcr.io/formbricks/data-migrations:v2.2
```
</CodeGroup>
@@ -288,12 +390,12 @@ docker compose up -d
<CodeGroup title="Migrate the data">
```bash
docker pull ghcr.io/formbricks/data-migrations:latest && \
docker pull ghcr.io/formbricks/data-migrations:v2.1.0 && \
docker run --rm \
--network=formbricks_default \
-e DATABASE_URL="postgresql://postgres:postgres@postgres:5432/formbricks?schema=public" \
-e UPGRADE_TO_VERSION="v2.1" \
ghcr.io/formbricks/data-migrations:latest
ghcr.io/formbricks/data-migrations:v2.1.0
```
</CodeGroup>
@@ -402,12 +504,12 @@ docker compose up -d
<CodeGroup title="Migrate the data">
```bash
docker pull ghcr.io/formbricks/data-migrations:latest && \
docker pull ghcr.io/formbricks/data-migrations:v2.0.3 && \
docker run --rm \
--network=formbricks_default \
-e DATABASE_URL="postgresql://postgres:postgres@postgres:5432/formbricks?schema=public" \
-e UPGRADE_TO_VERSION="v2.0" \
ghcr.io/formbricks/data-migrations:latest
ghcr.io/formbricks/data-migrations:v2.0.3
```
</CodeGroup>
@@ -677,6 +779,9 @@ 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"

View File

@@ -50,9 +50,10 @@ The script will prompt you for the following information:
<CodeGroup title="Docker GPG Keys Overwrite Prompt">
```bash
🧱 Welcome to the Formbricks single instance installer
🚀 Executing default step of installing Formbricks
🧱 Welcome to the Formbricks Setup Script
🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your Ubuntu 22.04.2 LTS server.
🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your Ubuntu 24.04 LTS server.
🧹 Time to sweep away any old Docker installations.
🔄 Updating your package list.
@@ -64,48 +65,21 @@ File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N)
</CodeGroup>
</Col>
2. **Email Address**: Provide your email address for SSL certificate registration with Let's Encrypt.
2. **Domain Name**: You will be asked to enter the domain name where you want to host Formbricks. This domain will be used to generate an SSL certificate.
<Col>
<CodeGroup title="Email Prompt">
```bash
🧱 Welcome to the Formbricks single instance installer
🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your Ubuntu 22.04.2 LTS server.
🚀 Executing default step of installing Formbricks
🧱 Welcome to the Formbricks Setup Script
🧹 Time to sweep away any old Docker installations.
🔄 Updating your package list.
📦 Installing the necessary dependencies.
🔑 Adding Docker's official GPG key and setting up the stable repository.
File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N) y
🔄 Updating your package list again.
🐳 Installing Docker.
🚀 Testing your Docker installation.
🎉 Docker is installed!
🐳 Adding your user to the Docker group to avoid using sudo with docker commands.
🎉 Hooray! Docker is all set and ready to go. Youre now ready to run your Formbricks instance!
🚗 Installing Traefik...
📁 Created Formbricks Quickstart directory at ./formbricks.
💡 Please enter your email address for the SSL certificate:
```
</CodeGroup>
</Col>
3. **Domain Name**: Enter the domain name that Traefik will use to create the SSL certificate and forward requests to Formbricks. Please make sure that port 80 and 443 are open in your VM's Security Group to allow Traefik to create the SSL certificate.
<Col>
<CodeGroup title="Domain Name for SSL certificate Prompt">
```bash
🧱 Welcome to the Formbricks single instance installer
🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your Ubuntu 22.04.2 LTS server.
🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your Ubuntu 24.04 LTS server.
🧹 Time to sweep away any old Docker installations.
🔄 Updating your package list.
📦 Installing the necessary dependencies.
🔑 Adding Dockers official GPG key and setting up the stable repository.
🔑 Adding Docker's official GPG key and setting up the stable repository.
File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N) y
🔄 Updating your package list again.
🐳 Installing Docker.
@@ -113,13 +87,186 @@ File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N) y
🎉 Docker is installed!
🐳 Adding your user to the Docker group to avoid using sudo with docker commands.
🎉 Hooray! Docker is all set and ready to go. You're now ready to run your Formbricks instance!
🚗 Installing Traefik...
📁 Created Formbricks Quickstart directory at ./formbricks.
🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):
```
</CodeGroup>
</Col>
3. **HTTPS Certificate Prompt**: The script will ask if you want to create an HTTPS certificate for your domain. Enter Y to proceed. This is highly recommended for secure access to your Formbricks instance.
<Col>
<CodeGroup>
```bash
🚀 Executing default step of installing Formbricks
🧱 Welcome to the Formbricks Setup Script
🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your Ubuntu 24.04 LTS server.
🧹 Time to sweep away any old Docker installations.
🔄 Updating your package list.
📦 Installing the necessary dependencies.
🔑 Adding Docker's official GPG key and setting up the stable repository.
File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N) y
🔄 Updating your package list again.
🐳 Installing Docker.
🚀 Testing your Docker installation.
🎉 Docker is installed!
🐳 Adding your user to the Docker group to avoid using sudo with docker commands.
🎉 Hooray! Docker is all set and ready to go. You're now ready to run your Formbricks instance!
📁 Created Formbricks Quickstart directory at ./formbricks.
🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):
my.hosted.url.com
🔗 Do you want us to set up an HTTPS certificate for you? [Y/n]
```
</CodeGroup>
</Col>
4. **DNS Setup Prompt**: Ensure that your domain's DNS is correctly configured and ports 80 and 443 are open. Confirm this by entering Y. This step is crucial for proper SSL certificate issuance and secure server access.
<Col>
<CodeGroup>
```bash
🚀 Executing default step of installing Formbricks
🧱 Welcome to the Formbricks Setup Script
🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your Ubuntu 24.04 LTS server.
🧹 Time to sweep away any old Docker installations.
🔄 Updating your package list.
📦 Installing the necessary dependencies.
🔑 Adding Docker's official GPG key and setting up the stable repository.
File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N) y
🔄 Updating your package list again.
🐳 Installing Docker.
🚀 Testing your Docker installation.
🎉 Docker is installed!
🐳 Adding your user to the Docker group to avoid using sudo with docker commands.
🎉 Hooray! Docker is all set and ready to go. You're now ready to run your Formbricks instance!
📁 Created Formbricks Quickstart directory at ./formbricks.
🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):
my.hosted.url.com
🔗 Do you want us to set up an HTTPS certificate for you? [Y/n]
Y
🔗 Please make sure that the domain points to the server's IP address and that ports 80 & 443 are open in your server's firewall. Is everything set up? [Y/n]
```
</CodeGroup>
</Col>
5. **Email Address**: Provide an email address for SSL certificate registration. This email will be used for notifications regarding your SSL certificate from Let's Encrypt.
<Col>
<CodeGroup title="Email Prompt">
```bash
🚀 Executing default step of installing Formbricks
🧱 Welcome to the Formbricks Setup Script
🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your Ubuntu 24.04 LTS server.
🧹 Time to sweep away any old Docker installations.
🔄 Updating your package list.
📦 Installing the necessary dependencies.
🔑 Adding Docker's official GPG key and setting up the stable repository.
File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N) y
🔄 Updating your package list again.
🐳 Installing Docker.
🚀 Testing your Docker installation.
🎉 Docker is installed!
🐳 Adding your user to the Docker group to avoid using sudo with docker commands.
🎉 Hooray! Docker is all set and ready to go. You're now ready to run your Formbricks instance!
📁 Created Formbricks Quickstart directory at ./formbricks.
🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):
my.hosted.url.com
🔗 Do you want us to set up an HTTPS certificate for you? [Y/n]
Y
🔗 Please make sure that the domain points to the server's IP address and that ports 80 & 443 are open in your server's firewall. Is everything set up? [Y/n]
Y
💡 Please enter your email address for the SSL certificate:
```
</CodeGroup>
</Col>
6. **Enforce HTTPS (HSTS) Prompt**: Enforcing HTTPS with HSTS is a good security practice, as it ensures all communication with your server is encrypted. Enter Y to enable this setting.
<Col>
<CodeGroup title="Domain Name for SSL certificate Prompt">
```bash
🚀 Executing default step of installing Formbricks
🧱 Welcome to the Formbricks Setup Script
🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your Ubuntu 24.04 LTS server.
🧹 Time to sweep away any old Docker installations.
🔄 Updating your package list.
📦 Installing the necessary dependencies.
🔑 Adding Docker's official GPG key and setting up the stable repository.
File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N) y
🔄 Updating your package list again.
🐳 Installing Docker.
🚀 Testing your Docker installation.
🎉 Docker is installed!
🐳 Adding your user to the Docker group to avoid using sudo with docker commands.
🎉 Hooray! Docker is all set and ready to go. You're now ready to run your Formbricks instance!
📁 Created Formbricks Quickstart directory at ./formbricks.
🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):
my.hosted.url.com
🔗 Do you want us to set up an HTTPS certificate for you? [Y/n]
Y
🔗 Please make sure that the domain points to the server's IP address and that ports 80 & 443 are open in your server's firewall. Is everything set up? [Y/n]
Y
💡 Please enter your email address for the SSL certificate:
docs@formbricks.com
💡 Created traefik.yaml file with your provided email address.
💡 Created acme.json file with correct permissions.
🔗 Do you want to enforce HTTPS (HSTS)? [Y/n]
```
</CodeGroup>
</Col>
7. **Email Service Setup Prompt**: The script will ask if you want to set up the email service. Enter `Y` to proceed.(default is `N`). You can skip this step if you don't want to set up the email service. You will still be able to use Formbricks without setting up the email service.
<Col>
<CodeGroup>
```bash
🚀 Executing default step of installing Formbricks
🧱 Welcome to the Formbricks Setup Script
🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your Ubuntu 24.04 LTS server.
🧹 Time to sweep away any old Docker installations.
🔄 Updating your package list.
📦 Installing the necessary dependencies.
🔑 Adding Docker's official GPG key and setting up the stable repository.
File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N) y
🔄 Updating your package list again.
🐳 Installing Docker.
🚀 Testing your Docker installation.
🎉 Docker is installed!
🐳 Adding your user to the Docker group to avoid using sudo with docker commands.
🎉 Hooray! Docker is all set and ready to go. You're now ready to run your Formbricks instance!
📁 Created Formbricks Quickstart directory at ./formbricks.
🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):
my.hosted.url.com
🔗 Do you want us to set up an HTTPS certificate for you? [Y/n]
Y
🔗 Please make sure that the domain points to the server's IP address and that ports 80 & 443 are open in your server's firewall. Is everything set up? [Y/n]
Y
💡 Please enter your email address for the SSL certificate:
docs@formbricks.com
🔗 Do you want to enforce HTTPS (HSTS)? [Y/n]
Y
🚗 Configuring Traefik...
💡 Created traefik.yaml and traefik-dynamic.yaml file.
💡 Created acme.json file with correct permissions.
📧 Do you want to set up the email service? You will need SMTP credentials for the same! [y/N]
```
</CodeGroup>
@@ -131,39 +278,56 @@ docs@formbricks.com
<CodeGroup title="Successfully setup Formbricks on your Ubuntu machine">
```bash
🧱 Welcome to the Formbricks single instance installer
🚀 Executing default step of installing Formbricks
🧱 Welcome to the Formbricks Setup Script
🛸 Fasten your seatbelts! Were setting up your Formbricks environment on your Ubuntu 22.04.2 LTS server.
🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your Ubuntu 24.04 LTS server.
🧹 Time to sweep away any old Docker installations.
🔄 Updating your package list.
📦 Installing the necessary dependencies.
🔑 Adding Dockers official GPG key and setting up the stable repository.
🔑 Adding Docker's official GPG key and setting up the stable repository.
File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N) y
🔄 Updating your package list again.
🐳 Installing Docker.
🚀 Testing your Docker installation.
🎉 Docker is installed!
🐳 Adding your user to the Docker group to avoid using sudo with docker commands.
🎉 Hooray! Docker is all set and ready to go. Youre now ready to run your Formbricks instance!
🚗 Installing Traefik...
🎉 Hooray! Docker is all set and ready to go. You're now ready to run your Formbricks instance!
📁 Created Formbricks Quickstart directory at ./formbricks.
🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):
docs@formbricks.com
🔗 Do you want us to set up an HTTPS certificate for you? [Y/n]
Y
🔗 Please make sure that the domain points to the server's IP address and that ports 80 & 443 are open in your server's firewall. Is everything set up? [Y/n]
Y
💡 Please enter your email address for the SSL certificate:
docs@formbricks.com
💡 Created traefik.yaml file with your provided email address.
🔗 Do you want to enforce HTTPS (HSTS)? [Y/n]
Y
🚗 Configuring Traefik...
💡 Created traefik.yaml and traefik-dynamic.yaml file.
💡 Created acme.json file with correct permissions.
🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):
my.hosted.url.com
🚙 Updating NEXTAUTH_SECRET in the Formbricks container...
📧 Do you want to set up the email service? You will need SMTP credentials for the same! [y/N] N
📥 Downloading docker-compose.yml from Formbricks GitHub repository...
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 6632 100 6632 0 0 24280 0 --:--:-- --:--:-- --:--:-- 24382
🚙 Updating docker-compose.yml with your custom inputs...
🚗 NEXTAUTH_SECRET updated successfully!
🚗 ENCRYPTION_KEY updated successfully!
🚗 CRON_SECRET updated successfully!
[+] Running 4/4
✔ Network formbricks_default Created 0.1s
✔ Container formbricks-postgres-1 Started 0.5s
✔ Container formbricks-formbricks-1 Started 0.7s
✔ Container traefik Started 1.1s
✔ Network formbricks_default Created 0.2s
✔ Container formbricks-postgres-1 Started 1.0s
✔ Container formbricks-formbricks-1 Started 1.6s
✔ Container traefik Started 2.8s
🔗 To edit more variables and deeper config, go to the formbricks/docker-compose.yml, edit the file, and restart the container!
🚨 Make sure you have set up the DNS records as well as inbound rules for the domain name and IP address of this instance.
🎉 All done! Check the status of Formbricks & Traefik with 'cd formbricks && sudo docker compose ps.'
🎉 All done! Please setup your Formbricks instance by visiting your domain at https://tls.piyush.formbricks.com. You can check the status of Formbricks & Traefik with 'cd formbricks && sudo docker compose ps.'
```
</CodeGroup>

View File

@@ -12,34 +12,34 @@
},
"browserslist": "defaults, not ie <= 11",
"dependencies": {
"@algolia/autocomplete-core": "^1.17.2",
"@algolia/autocomplete-core": "^1.17.4",
"@calcom/embed-react": "^1.5.0",
"@docsearch/css": "3",
"@docsearch/react": "^3.6.0",
"@docsearch/react": "^3.6.1",
"@formbricks/lib": "workspace:*",
"@formbricks/types": "workspace:*",
"@formbricks/ui": "workspace:*",
"@headlessui/react": "^2.1.1",
"@headlessui/react": "^2.1.2",
"@headlessui/tailwindcss": "^0.2.1",
"@mapbox/rehype-prism": "^0.9.0",
"@mdx-js/loader": "^3.0.1",
"@mdx-js/react": "^3.0.1",
"@next/mdx": "14.2.4",
"@next/mdx": "14.2.5",
"@paralleldrive/cuid2": "^2.2.2",
"@sindresorhus/slugify": "^2.2.1",
"@tailwindcss/typography": "^0.5.13",
"acorn": "^8.12.0",
"acorn": "^8.12.1",
"autoprefixer": "^10.4.19",
"clsx": "^2.1.1",
"fast-glob": "^3.3.2",
"flexsearch": "^0.7.43",
"framer-motion": "11.2.12",
"framer-motion": "11.3.20",
"lottie-web": "^5.12.2",
"lucide": "^0.397.0",
"lucide-react": "^0.397.0",
"lucide": "^0.418.0",
"lucide-react": "^0.418.0",
"mdast-util-to-string": "^4.0.0",
"mdx-annotations": "^0.1.4",
"next": "14.2.4",
"next": "14.2.5",
"next-plausible": "^3.12.0",
"next-seo": "^6.5.0",
"next-sitemap": "^4.2.3",
@@ -59,7 +59,7 @@
"sharp": "^0.33.4",
"shiki": "^0.14.7",
"simple-functional-loader": "^1.2.1",
"tailwindcss": "^3.4.4",
"tailwindcss": "^3.4.7",
"unist-util-filter": "^5.0.1",
"unist-util-visit": "^5.0.0",
"zustand": "^4.5.4"

View File

@@ -21,6 +21,7 @@ const config: StorybookConfig = {
getAbsolutePath("@storybook/addon-essentials"),
getAbsolutePath("@chromatic-com/storybook"),
getAbsolutePath("@storybook/addon-interactions"),
getAbsolutePath("@storybook/addon-a11y"),
],
framework: {
name: getAbsolutePath("@storybook/react-vite"),

View File

@@ -12,29 +12,30 @@
},
"dependencies": {
"@formbricks/ui": "workspace:*",
"eslint-plugin-react-refresh": "^0.4.7",
"eslint-plugin-react-refresh": "^0.4.9",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@chromatic-com/storybook": "^1.5.0",
"@chromatic-com/storybook": "^1.6.1",
"@formbricks/config-typescript": "workspace:*",
"@storybook/addon-essentials": "^8.1.10",
"@storybook/addon-interactions": "^8.1.10",
"@storybook/addon-links": "^8.1.10",
"@storybook/addon-onboarding": "^8.1.10",
"@storybook/blocks": "^8.1.10",
"@storybook/react": "^8.1.10",
"@storybook/react-vite": "^8.1.10",
"@storybook/test": "^8.1.10",
"@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1",
"@storybook/addon-a11y": "^8.2.7",
"@storybook/addon-essentials": "^8.2.7",
"@storybook/addon-interactions": "^8.2.7",
"@storybook/addon-links": "^8.2.7",
"@storybook/addon-onboarding": "^8.2.7",
"@storybook/blocks": "^8.2.7",
"@storybook/react": "^8.2.7",
"@storybook/react-vite": "^8.2.7",
"@storybook/test": "^8.2.7",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitejs/plugin-react": "^4.3.1",
"esbuild": "^0.21.5",
"esbuild": "^0.23.0",
"eslint-plugin-storybook": "^0.8.0",
"prop-types": "^15.8.1",
"storybook": "^8.1.10",
"tsup": "^8.1.0",
"vite": "^5.3.1"
"storybook": "^8.2.7",
"tsup": "^8.2.3",
"vite": "^5.3.5"
}
}

View File

@@ -73,17 +73,19 @@ export const ConnectWithFormbricks = ({
) : (
<div className="space-y-4">
<Image src={Lost} alt="lost" height={250} />
<p className="pt-4 text-slate-400">Waiting for your signal...</p>
<p className="animate-pulse pt-4 text-sm font-semibold text-slate-700">
Waiting for your signal...
</p>
</div>
)}
</div>
</div>
<Button
id="finishOnboarding"
variant={widgetSetupCompleted ? "darkCTA" : "minimal"}
variant={widgetSetupCompleted ? "primary" : "minimal"}
onClick={handleFinishOnboarding}
EndIcon={ArrowRight}>
{widgetSetupCompleted ? "Finish Onboarding" : "Skip"}
{widgetSetupCompleted ? "Finish Onboarding" : "I don't know how to do it"}
</Button>
</div>
);

View File

@@ -103,13 +103,9 @@ export const InviteOrganizationMember = ({ organization, environmentId }: Invite
e.preventDefault();
finishOnboarding();
}}>
Skip
Not now
</Button>
<Button
id="onboarding-inapp-invite-send-invite"
variant="darkCTA"
type={"submit"}
loading={isSubmitting}>
<Button id="onboarding-inapp-invite-send-invite" type={"submit"} loading={isSubmitting}>
Invite
</Button>
</div>

View File

@@ -136,7 +136,7 @@ export const OnboardingSetupInstructions = ({
<div className="mt-4 flex justify-between space-x-2">
<Button
id="onboarding-inapp-connect-copy-code"
variant={widgetSetupCompleted ? "secondary" : "darkCTA"}
variant={widgetSetupCompleted ? "secondary" : "primary"}
onClick={() => {
navigator.clipboard.writeText(
channel === "app" ? htmlSnippetForAppSurveys : htmlSnippetForWebsiteSurveys

View File

@@ -32,8 +32,8 @@ const Page = async ({ params }: InvitePageProps) => {
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center">
<Header
title="Invite your organization to help out"
subtitle="Ask your tech-savvy co-worker to finish the setup:"
title="Who is your favorite engineer?"
subtitle="Invite your tech-savvy co-worker to help with the setup 🤓"
/>
<div className="space-y-4 text-center">
<p className="text-4xl font-medium text-slate-800"></p>

View File

@@ -38,7 +38,7 @@ const Page = async ({ params }: ConnectPageProps) => {
<div className="flex min-h-full flex-col items-center justify-center py-10">
<Header
title={`Let's connect your ${customHeadline} with Formbricks`}
subtitle="If you don't do it now, chances are low that you will ever do it!"
subtitle="It takes less than 4 minutes, pinky promise!"
/>
<div className="space-y-4 text-center">
<p className="text-4xl font-medium text-slate-800"></p>

View File

@@ -4,8 +4,10 @@ export const getCustomHeadline = (channel: TProductConfigChannel, industry: TPro
const combinations = {
"website+eCommerce": "web shop",
"website+saas": "landing page",
"website+other": "website",
"app+eCommerce": "shopping app",
"app+saas": "SaaS app",
"app+other": "app",
};
return combinations[`${channel}+${industry}`] || "app";
return combinations[`${channel}+${industry}`] || "product";
};

View File

@@ -5,6 +5,7 @@ import { authOptions } from "@formbricks/lib/authOptions";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { canUserAccessOrganization } from "@formbricks/lib/organization/auth";
import { getOrganization } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { AuthorizationError } from "@formbricks/types/errors";
import { ToasterClient } from "@formbricks/ui/ToasterClient";
@@ -14,6 +15,11 @@ const ProductOnboardingLayout = async ({ children, params }) => {
return redirect(`/auth/login`);
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error("User not found");
}
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
if (!isAuthorized) {
throw AuthorizationError;
@@ -31,6 +37,7 @@ const ProductOnboardingLayout = async ({ children, params }) => {
<div className="flex-1 bg-slate-50">
<PosthogIdentify
session={session}
user={user}
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}

View File

@@ -1,5 +1,5 @@
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { CircleUserRoundIcon, EarthIcon, SendHorizonalIcon, XIcon } from "lucide-react";
import { GlobeIcon, GlobeLockIcon, LinkIcon, XIcon } from "lucide-react";
import { getProducts } from "@formbricks/lib/product/service";
import { Button } from "@formbricks/ui/Button";
import { Header } from "@formbricks/ui/Header";
@@ -14,24 +14,24 @@ const Page = async ({ params }: ChannelPageProps) => {
const channelOptions = [
{
title: "Public website",
description: "Display surveys on public websites, well timed and targeted.",
icon: EarthIcon,
description: "Run well-timed pop-up surveys.",
icon: GlobeIcon,
iconText: "Built for scale",
href: `/organizations/${params.organizationId}/products/new/industry?channel=website`,
},
{
title: "App with sign up",
description: "Run highly targeted surveys with any user cohort.",
icon: CircleUserRoundIcon,
description: "Run highly-targeted micro-surveys.",
icon: GlobeLockIcon,
iconText: "Enrich user profiles",
href: `/organizations/${params.organizationId}/products/new/industry?channel=app`,
},
{
channel: "link",
title: "Anywhere online",
description: "Create link and email surveys, reach your people anywhere.",
icon: SendHorizonalIcon,
iconText: "100% custom branding",
title: "Link & email surveys",
description: "Reach people anywhere online.",
icon: LinkIcon,
iconText: "Anywhere online",
href: `/organizations/${params.organizationId}/products/new/industry?channel=link`,
},
];
@@ -42,7 +42,7 @@ const Page = async ({ params }: ChannelPageProps) => {
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
title="Where do you want to survey people?"
subtitle="Get started with proven best practices 🚀"
subtitle="Run surveys on public websites, in your app, or with shareable links & emails."
/>
<OnboardingOptionsContainer options={channelOptions} />
{products.length >= 1 && (

View File

@@ -1,7 +1,6 @@
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { HeartIcon, MonitorIcon, ShoppingCart, XIcon } from "lucide-react";
import { notFound } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getProducts } from "@formbricks/lib/product/service";
import { TProductConfigChannel } from "@formbricks/types/product";
import { Button } from "@formbricks/ui/Button";
@@ -27,32 +26,33 @@ const Page = async ({ params, searchParams }: IndustryPageProps) => {
const industryOptions = [
{
title: "E-Commerce",
description: "Implement proven best practices to understand why people buy.",
description: "Understand why people buy.",
icon: ShoppingCart,
iconText: "B2B and B2C",
href: `/organizations/${params.organizationId}/products/new/settings?channel=${channel}&industry=eCommerce`,
},
{
title: "SaaS",
description: "Gather contextualized feedback to improve product-market fit.",
description: "Improve product-market fit.",
icon: MonitorIcon,
iconText: "Proven methods",
href: `/organizations/${params.organizationId}/products/new/settings?channel=${channel}&industry=saas`,
},
{
title: "Other",
description: "Universal Formricks experience with features for every industry.",
description: "Listen to your customers.",
icon: HeartIcon,
iconText: "Customer insights",
href: IS_FORMBRICKS_CLOUD
? `/organizations/${params.organizationId}/products/new/survey?channel=${channel}&industry=other`
: `/organizations/${params.organizationId}/products/new/settings?channel=${channel}&industry=other`,
href: `/organizations/${params.organizationId}/products/new/settings?channel=${channel}&industry=other`,
},
];
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title="Which industry do you work for?" subtitle="Get started with proven best practices 🚀" />
<Header
title="Which industry do you work for?"
subtitle="Get started with battle-tested best practices."
/>
<OnboardingOptionsContainer options={industryOptions} />
{products.length >= 1 && (
<Button

View File

@@ -97,7 +97,7 @@ export const ProductSettings = ({
<FormItem className="w-full space-y-4">
<div>
<FormLabel>Brand color</FormLabel>
<FormDescription>Change the brand color of the survey.</FormDescription>
<FormDescription>Match the main color of surveys with your brand.</FormDescription>
</div>
<FormControl>
<div>
@@ -118,9 +118,9 @@ export const ProductSettings = ({
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full space-y-4">
<div>
<FormLabel>Product Name</FormLabel>
<FormLabel>Product name</FormLabel>
<FormDescription>
What is your {getCustomHeadline(channel, industry)} called ?
What is your {getCustomHeadline(channel, industry)} called?
</FormDescription>
</div>
<FormControl>
@@ -128,7 +128,7 @@ export const ProductSettings = ({
<Input
value={field.value}
onChange={(name) => field.onChange(name)}
placeholder="Formbricks Merch Store"
placeholder="e.g. Formbricks"
className="bg-white"
autoFocus={true}
/>
@@ -140,7 +140,7 @@ export const ProductSettings = ({
/>
<div className="flex w-full justify-end">
<Button variant="darkCTA" loading={isSubmitting} type="submit">
<Button loading={isSubmitting} type="submit">
Next
</Button>
</div>

View File

@@ -35,7 +35,7 @@ const Page = async ({ params, searchParams }: ProductSettingsPageProps) => {
/>
) : (
<Header
title={`You run ${startsWithVowel(customHeadline) ? "an " + customHeadline : "a " + customHeadline}, how exciting!`}
title={`You maintain ${startsWithVowel(customHeadline) ? "an " + customHeadline : "a " + customHeadline}, how exciting!`}
subtitle="Get 2x more responses matching surveys with your brand and UI"
/>
)}

View File

@@ -1,48 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import { TProductConfigChannel } from "@formbricks/types/product";
interface OnboardingSurveyProps {
organizationId: string;
channel: TProductConfigChannel;
userId: string;
}
export const OnboardingSurvey = ({ organizationId, channel, userId }: OnboardingSurveyProps) => {
const [isIFrameVisible, setIsIFrameVisible] = useState(false);
const [fadeout, setFadeout] = useState(false);
const router = useRouter();
const handleMessageEvent = (event: MessageEvent) => {
if (event.data === "formbricksSurveyCompleted") {
setFadeout(true); // Start fade-out
setTimeout(() => {
router.push(
`/organizations/${organizationId}/products/new/settings?channel=${channel}&industry=other`
);
}, 800); // Delay the navigation until fade-out completes
}
};
useEffect(() => {
if (isIFrameVisible) {
window.addEventListener("message", handleMessageEvent, false);
return () => {
window.removeEventListener("message", handleMessageEvent, false);
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isIFrameVisible]);
return (
<div
className={`overflow relative flex h-[100vh] flex-col items-center justify-center ${fadeout ? "opacity-0 transition-opacity duration-1000" : "opacity-100"}`}>
<iframe
onLoad={() => setIsIFrameVisible(true)}
src={`https://app.formbricks.com/s/clxcwr22p0cwlpvgekzdab2x5?userId=${userId}`}
className="absolute left-0 top-0 h-full w-full overflow-visible border-0"></iframe>
</div>
);
};

View File

@@ -1,32 +0,0 @@
import { OnboardingSurvey } from "@/app/(app)/(onboarding)/organizations/[organizationId]/products/new/survey/components/OnboardingSurvey";
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { authOptions } from "@formbricks/lib/authOptions";
import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
interface OnboardingSurveyPageProps {
params: {
organizationId: string;
};
searchParams: {
channel?: TProductConfigChannel;
industry?: TProductConfigIndustry;
};
}
const Page = async ({ params, searchParams }: OnboardingSurveyPageProps) => {
const session = await getServerSession(authOptions);
if (!session) {
return redirect(`/auth/login`);
}
const channel = searchParams.channel;
const industry = searchParams.industry;
if (!channel || !industry) return notFound();
return (
<OnboardingSurvey organizationId={params.organizationId} channel={channel} userId={session.user.id} />
);
};
export default Page;

View File

@@ -7,6 +7,7 @@ import { authOptions } from "@formbricks/lib/authOptions";
import { INVITE_DISABLED } from "@formbricks/lib/constants";
import { inviteUser } from "@formbricks/lib/invite/service";
import { verifyUserRoleAccess } from "@formbricks/lib/organization/auth";
import { getUser } from "@formbricks/lib/user/service";
import { AuthenticationError } from "@formbricks/types/errors";
import { TMembershipRole } from "@formbricks/types/memberships";
@@ -22,6 +23,12 @@ export const inviteOrganizationMemberAction = async (
throw new AuthenticationError("Not authenticated");
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error("User not found");
}
const isUserAuthorized = await hasOrganizationAuthority(session.user.id, organizationId);
if (INVITE_DISABLED) {
@@ -50,7 +57,7 @@ export const inviteOrganizationMemberAction = async (
await sendInviteMemberEmail(
invite.id,
email,
session.user.name ?? "",
user.name ?? "",
"",
true, // is onboarding invite
inviteMessage

View File

@@ -7,6 +7,7 @@ import { authOptions } from "@formbricks/lib/authOptions";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { AuthorizationError } from "@formbricks/types/errors";
import { DevEnvironmentBanner } from "@formbricks/ui/DevEnvironmentBanner";
import { ToasterClient } from "@formbricks/ui/ToasterClient";
@@ -16,6 +17,12 @@ const EnvLayout = async ({ children, params }) => {
if (!session || !session.user) {
return redirect(`/auth/login`);
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error("User not found");
}
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
if (!hasAccess) {
throw new AuthorizationError("Not authorized");
@@ -37,12 +44,13 @@ const EnvLayout = async ({ children, params }) => {
<ResponseFilterProvider>
<PosthogIdentify
session={session}
user={user}
environmentId={params.environmentId}
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
/>
<FormbricksClient session={session} />
<FormbricksClient session={session} userEmail={user.email} />
<ToasterClient />
<div className="flex h-screen flex-col">
<DevEnvironmentBanner environment={environment} />

View File

@@ -23,7 +23,7 @@ import {
loadNewSegmentInSurvey,
updateSurvey,
} from "@formbricks/lib/survey/service";
import { TActionClassInput } from "@formbricks/types/actionClasses";
import { TActionClassInput } from "@formbricks/types/action-classes";
import { AuthorizationError } from "@formbricks/types/errors";
import { TProduct } from "@formbricks/types/product";
import { TBaseFilters, TSegmentUpdateInput, ZSegmentFilters } from "@formbricks/types/segment";

View File

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

View File

@@ -0,0 +1,25 @@
"use client";
import { PlusIcon } from "lucide-react";
import { TSurvey } from "@formbricks/types/surveys/types";
interface AddEndingCardButtonProps {
localSurvey: TSurvey;
setLocalSurvey: (survey: TSurvey) => void;
addEndingCard: (index: number) => void;
}
export const AddEndingCardButton = ({ localSurvey, addEndingCard }: AddEndingCardButtonProps) => {
return (
<div
className="group inline-flex rounded-lg border border-slate-300 bg-slate-50 hover:cursor-pointer hover:bg-white"
onClick={() => addEndingCard(localSurvey.endings.length)}>
<div className="flex w-10 items-center justify-center rounded-l-lg bg-slate-400 transition-all duration-300 ease-in-out group-hover:bg-slate-500 group-aria-expanded:rounded-bl-none group-aria-expanded:rounded-br">
<PlusIcon className="h-6 w-6 text-white" />
</div>
<div className="px-4 py-3 text-sm">
<p className="font-semibold">Add Ending</p>
</div>
</div>
);
};

View File

@@ -21,17 +21,17 @@ export const AddQuestionButton = ({ addQuestion, product }: AddQuestionButtonPro
open={open}
onOpenChange={setOpen}
className={cn(
open ? "scale-100 shadow-lg" : "scale-97 shadow-md",
"group w-full space-y-2 rounded-lg border border-slate-300 bg-white transition-all duration-300 ease-in-out hover:scale-100 hover:cursor-pointer hover:bg-slate-50"
open ? "shadow-lg" : "shadow-md",
"group w-full space-y-2 rounded-lg border border-slate-300 bg-white duration-300 hover:cursor-pointer hover:bg-slate-50"
)}>
<Collapsible.CollapsibleTrigger asChild className="group h-full w-full">
<div className="inline-flex">
<div className="bg-brand-dark flex w-10 items-center justify-center rounded-l-lg group-aria-expanded:rounded-bl-none group-aria-expanded:rounded-br">
<PlusIcon className="h-6 w-6 text-white" />
<PlusIcon className="h-5 w-5 text-white" />
</div>
<div className="px-4 py-3">
<p className="font-semibold">Add Question</p>
<p className="mt-1 text-sm text-slate-500">Add a new question to your survey</p>
<p className="text-sm font-semibold">Add Question</p>
<p className="mt-1 text-xs text-slate-500">Add a new question to your survey</p>
</div>
</div>
</Collapsible.CollapsibleTrigger>
@@ -41,7 +41,7 @@ export const AddQuestionButton = ({ addQuestion, product }: AddQuestionButtonPro
<button
type="button"
key={questionType.id}
className="mx-2 inline-flex items-center rounded p-0.5 px-4 py-2 font-medium text-slate-700 last:mb-2 hover:bg-slate-100 hover:text-slate-800"
className="mx-2 inline-flex items-center rounded p-0.5 px-4 py-2 text-sm font-medium text-slate-700 last:mb-2 hover:bg-slate-100 hover:text-slate-800"
onClick={() => {
addQuestion({
...universalQuestionPresets,
@@ -51,7 +51,7 @@ export const AddQuestionButton = ({ addQuestion, product }: AddQuestionButtonPro
});
setOpen(false);
}}>
<questionType.icon className="text-brand-dark -ml-0.5 mr-2 h-5 w-5" aria-hidden="true" />
<questionType.icon className="text-brand-dark -ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
{questionType.label}
</button>
))}

View File

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

View File

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

View File

@@ -2,12 +2,20 @@
import { useState } from "react";
import { LocalizedEditor } from "@formbricks/ee/multi-language/components/localized-editor";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyCTAQuestion } from "@formbricks/types/surveys/types";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { OptionsSwitch } from "@formbricks/ui/OptionsSwitch";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup";
const options = [
{
value: "internal",
label: "Button to continue in survey",
},
{ value: "external", label: "Button to link to external URL" },
];
interface CTAQuestionFormProps {
localSurvey: TSurvey;
@@ -66,25 +74,13 @@ export const CTAQuestionForm = ({
/>
</div>
</div>
<RadioGroup
className="mt-3 flex"
defaultValue="internal"
value={question.buttonExternal ? "external" : "internal"}
onValueChange={(e) => updateQuestion(questionIdx, { buttonExternal: e === "external" })}>
<div className="flex items-center space-x-2 rounded-lg border border-slate-200 p-3 dark:border-slate-500">
<RadioGroupItem value="internal" id="internal" className="bg-slate-50" />
<Label htmlFor="internal" className="cursor-pointer dark:text-slate-200">
Button to continue in survey
</Label>
</div>
<div className="flex items-center space-x-2 rounded-lg border border-slate-200 p-3 dark:border-slate-500">
<RadioGroupItem value="external" id="external" className="bg-slate-50" />
<Label htmlFor="external" className="cursor-pointer dark:text-slate-200">
Button to link to external URL
</Label>
</div>
</RadioGroup>
<div className="mt-3">
<OptionsSwitch
options={options}
currentOption={question.buttonExternal ? "external" : "internal"}
handleOptionChange={(e) => updateQuestion(questionIdx, { buttonExternal: e === "external" })}
/>
</div>
<div className="mt-2 flex justify-between gap-8">
<div className="flex w-full space-x-2">

View File

@@ -1,10 +1,10 @@
import { PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyCalQuestion } from "@formbricks/types/surveys/types";
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
import { Button } from "@formbricks/ui/Button";
import { Checkbox } from "@formbricks/ui/Checkbox";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
@@ -37,6 +37,8 @@ export const CalQuestionForm = ({
useEffect(() => {
if (!isCalHostEnabled) {
updateQuestion(questionIdx, { calHost: undefined });
} else {
updateQuestion(questionIdx, { calHost: question.calHost ?? "cal.com" });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -91,9 +93,9 @@ export const CalQuestionForm = ({
Add Description
</Button>
)}
<div className="mt-3 flex flex-col gap-4">
<div className="mt-3 flex flex-col gap-6">
<div className="flex flex-col gap-3">
<Label htmlFor="calUserName">Add your Cal.com username or username/event</Label>
<Label htmlFor="calUserName">Cal.com username or username/event</Label>
<div>
<Input
id="calUserName"
@@ -104,29 +106,28 @@ export const CalQuestionForm = ({
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Checkbox
id="calHost"
checked={isCalHostEnabled}
onCheckedChange={(checked: boolean) => setIsCalHostEnabled(checked)}
/>
<Label htmlFor="calHost">Do you have a self-hosted Cal.com instance?</Label>
</div>
{isCalHostEnabled && (
<div className="flex flex-col gap-2">
<Label htmlFor="calHost">Enter the hostname of your self-hosted Cal.com instance</Label>
<AdvancedOptionToggle
isChecked={isCalHostEnabled}
onToggle={(checked: boolean) => setIsCalHostEnabled(checked)}
htmlId="calHost"
description="Needed for a self-hosted Cal.com instance"
childBorder
title="Custom hostname"
customContainerClass="p-0">
<div className="p-4">
<div className="flex items-center gap-2">
<Label htmlFor="calHost">Hostname</Label>
<Input
id="calHost"
name="calHost"
placeholder="cal.com"
placeholder="my-cal-instance.com"
value={question.calHost}
className="bg-white"
onChange={(e) => updateQuestion(questionIdx, { calHost: e.target.value })}
/>
</div>
)}
</div>
</div>
</AdvancedOptionToggle>
</div>
</div>
</form>

View File

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

View File

@@ -9,7 +9,7 @@ import {
TActionClassInput,
TActionClassInputCode,
ZActionClassInput,
} from "@formbricks/types/actionClasses";
} from "@formbricks/types/action-classes";
import { TSurvey } from "@formbricks/types/surveys/types";
import { Button } from "@formbricks/ui/Button";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@formbricks/ui/Form";
@@ -249,7 +249,7 @@ export const CreateNewActionTab = ({
<Button type="button" variant="minimal" onClick={resetAllStates}>
Cancel
</Button>
<Button variant="darkCTA" type="submit" loading={isSubmitting}>
<Button type="submit" loading={isSubmitting}>
Create action
</Button>
</div>

View File

@@ -1,11 +1,11 @@
import { PlusIcon } from "lucide-react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyDateQuestion } from "@formbricks/types/surveys/types";
import { Button } from "@formbricks/ui/Button";
import { Label } from "@formbricks/ui/Label";
import { OptionsSwitch } from "@formbricks/ui/OptionsSwitch";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
import { OptionsSwitcher } from "@formbricks/ui/QuestionTypeSelector";
interface IDateQuestionFormProps {
localSurvey: TSurvey;
@@ -100,10 +100,10 @@ export const DateQuestionForm = ({
<div className="mt-3">
<Label htmlFor="questionType">Date Format</Label>
<div className="mt-2 flex items-center">
<OptionsSwitcher
<OptionsSwitch
options={dateOptions}
currentOption={question.format}
handleTypeChange={(value: "M-d-y" | "d-M-y" | "y-M-d") =>
handleOptionChange={(value: "M-d-y" | "d-M-y" | "y-M-d") =>
updateQuestion(questionIdx, { format: value })
}
/>

View File

@@ -0,0 +1,230 @@
"use client";
import { EditorCardMenu } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditorCardMenu";
import { EndScreenForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EndScreenForm";
import { RedirectUrlForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RedirectUrlForm";
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { createId } from "@paralleldrive/cuid2";
import * as Collapsible from "@radix-ui/react-collapsible";
import { GripIcon } from "lucide-react";
import { cn } from "@formbricks/lib/cn";
import { recallToHeadline } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
import { TSurvey, TSurveyEndScreenCard, TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types";
import { OptionsSwitch } from "@formbricks/ui/OptionsSwitch";
import { TooltipRenderer } from "@formbricks/ui/Tooltip";
interface EditEndingCardProps {
localSurvey: TSurvey;
endingCardIndex: number;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
setActiveQuestionId: (id: string | null) => void;
activeQuestionId: string | null;
isInvalid: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
attributeClasses: TAttributeClass[];
plan: TOrganizationBillingPlan;
addEndingCard: (index: number) => void;
isFormbricksCloud: boolean;
}
const endingCardTypes = [
{ value: "endScreen", label: "Ending card" },
{ value: "redirectToUrl", label: "Redirect to Url" },
];
export const EditEndingCard = ({
localSurvey,
endingCardIndex,
setLocalSurvey,
setActiveQuestionId,
activeQuestionId,
isInvalid,
selectedLanguageCode,
setSelectedLanguageCode,
attributeClasses,
plan,
addEndingCard,
isFormbricksCloud,
}: EditEndingCardProps) => {
const endingCard = localSurvey.endings[endingCardIndex];
const isRedirectToUrlDisabled = isFormbricksCloud
? plan === "free" && endingCard.type !== "redirectToUrl"
: false;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: endingCard.id,
});
let open = activeQuestionId === endingCard.id;
const setOpen = (e) => {
if (e) {
setActiveQuestionId(endingCard.id);
} else {
setActiveQuestionId(null);
}
};
const updateSurvey = (data: Partial<TSurveyEndScreenCard> | Partial<TSurveyRedirectUrlCard>) => {
setLocalSurvey((prevSurvey) => {
const updatedEndings = prevSurvey.endings.map((ending, idx) =>
idx === endingCardIndex ? { ...ending, ...data } : ending
);
return { ...prevSurvey, endings: updatedEndings };
});
};
const deleteEndingCard = () => {
setLocalSurvey((prevSurvey) => {
const updatedEndings = prevSurvey.endings.filter((_, index) => index !== endingCardIndex);
return { ...prevSurvey, endings: updatedEndings };
});
};
const style = {
transition: transition ?? "transform 100ms ease",
transform: CSS.Translate.toString(transform),
zIndex: isDragging ? 10 : 1,
};
const duplicateEndingCard = () => {
setLocalSurvey((prevSurvey) => {
const endingToDuplicate = prevSurvey.endings[endingCardIndex];
const duplicatedEndingCard = {
...endingToDuplicate,
id: createId(),
};
const updatedEndings = [
...prevSurvey.endings.slice(0, endingCardIndex + 1),
duplicatedEndingCard,
...prevSurvey.endings.slice(endingCardIndex + 1),
];
return { ...prevSurvey, endings: updatedEndings };
});
};
const moveEndingCard = (index: number, up: boolean) => {
setLocalSurvey((prevSurvey) => {
const newEndings = [...prevSurvey.endings];
const [movedEnding] = newEndings.splice(index, 1);
newEndings.splice(up ? index - 1 : index + 1, 0, movedEnding);
return { ...prevSurvey, endings: newEndings };
});
};
return (
<div
className={cn(open ? "shadow-lg" : "shadow-md", "group z-20 flex flex-row rounded-lg bg-white")}
ref={setNodeRef}
style={style}
id={endingCard.id}>
<div
{...listeners}
{...attributes}
className={cn(
open ? "bg-slate-50" : "",
"flex w-10 flex-col items-center justify-between rounded-l-lg border-b border-l border-t py-2 group-aria-expanded:rounded-bl-none",
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
)}>
<p className="mt-3">{endingCard.type === "endScreen" ? "🙏" : "↪️"}</p>
<button className="opacity-0 transition-all duration-300 hover:cursor-move group-hover:opacity-100">
<GripIcon className="h-4 w-4" />
</button>
</div>
<Collapsible.Root
open={open}
onOpenChange={setOpen}
className="flex-1 rounded-r-lg border border-slate-200 transition-all duration-300 ease-in-out">
<Collapsible.CollapsibleTrigger
asChild
className="flex cursor-pointer justify-between rounded-r-lg p-5 hover:bg-slate-50">
<div>
<div className="inline-flex">
<div>
<p className="text-sm font-semibold">
{endingCard.type === "endScreen" &&
(endingCard.headline &&
recallToHeadline(
endingCard.headline,
localSurvey,
true,
selectedLanguageCode,
attributeClasses
)[selectedLanguageCode]
? formatTextWithSlashes(
recallToHeadline(
endingCard.headline,
localSurvey,
true,
selectedLanguageCode,
attributeClasses
)[selectedLanguageCode]
)
: "Ending card")}
{endingCard.type === "redirectToUrl" && (endingCard.label || "Redirect to Url")}
</p>
{!open && (
<p className="mt-1 truncate text-xs text-slate-500">
{endingCard.type === "endScreen" ? "Ending card" : "Redirect to Url"}
</p>
)}
</div>
</div>
<div className="flex items-center space-x-4">
<EditorCardMenu
survey={localSurvey}
cardIdx={endingCardIndex}
lastCard={endingCardIndex === localSurvey.endings.length - 1}
duplicateCard={duplicateEndingCard}
deleteCard={deleteEndingCard}
moveCard={moveEndingCard}
card={endingCard}
updateCard={() => {}}
addCard={addEndingCard}
cardType="ending"
/>
</div>
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="mt-3 px-4 pb-6">
<TooltipRenderer
shouldRender={endingCard.type === "endScreen" && isRedirectToUrlDisabled}
tooltipContent={"Redirect To Url is not available on free plan"}
triggerClass="w-full">
<OptionsSwitch
options={endingCardTypes}
currentOption={endingCard.type}
handleOptionChange={() => {
if (endingCard.type === "endScreen") {
updateSurvey({ type: "redirectToUrl" });
} else {
updateSurvey({ type: "endScreen" });
}
}}
disabled={isRedirectToUrlDisabled}
/>
</TooltipRenderer>
{endingCard.type === "endScreen" && (
<EndScreenForm
localSurvey={localSurvey}
endingCardIndex={endingCardIndex}
isInvalid={isInvalid}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
updateSurvey={updateSurvey}
endingCard={endingCard}
/>
)}
{endingCard.type === "redirectToUrl" && (
<RedirectUrlForm endingCard={endingCard} updateSurvey={updateSurvey} />
)}
</Collapsible.CollapsibleContent>
</Collapsible.Root>
</div>
);
};

View File

@@ -1,200 +0,0 @@
"use client";
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/attributeClasses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
import { Switch } from "@formbricks/ui/Switch";
interface EditThankYouCardProps {
localSurvey: TSurvey;
setLocalSurvey: (survey: TSurvey) => void;
setActiveQuestionId: (id: string | null) => void;
activeQuestionId: string | null;
isInvalid: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
attributeClasses: TAttributeClass[];
}
export const EditThankYouCard = ({
localSurvey,
setLocalSurvey,
setActiveQuestionId,
activeQuestionId,
isInvalid,
selectedLanguageCode,
setSelectedLanguageCode,
attributeClasses,
}: EditThankYouCardProps) => {
// const [open, setOpen] = useState(false);
let open = activeQuestionId == "end";
const [showThankYouCardCTA, setshowThankYouCardCTA] = useState<boolean>(
getLocalizedValue(localSurvey.thankYouCard.buttonLabel, "default") || localSurvey.thankYouCard.buttonLink
? true
: false
);
const setOpen = (e) => {
if (e) {
setActiveQuestionId("end");
} else {
setActiveQuestionId(null);
}
};
const updateSurvey = (data) => {
const updatedSurvey = {
...localSurvey,
thankYouCard: {
...localSurvey.thankYouCard,
...data,
},
};
setLocalSurvey(updatedSurvey);
};
return (
<div
className={cn(
open ? "scale-100 shadow-lg" : "scale-97 shadow-md",
"group z-20 flex flex-row rounded-lg bg-white transition-transform duration-300 ease-in-out"
)}>
<div
className={cn(
open ? "bg-slate-50" : "",
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none",
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
)}>
<p>🙏</p>
</div>
<Collapsible.Root
open={open}
onOpenChange={setOpen}
className="flex-1 rounded-r-lg border border-slate-200 transition-all duration-300 ease-in-out">
<Collapsible.CollapsibleTrigger
asChild
className="flex cursor-pointer justify-between p-4 hover:bg-slate-50">
<div>
<div className="inline-flex">
<div>
<p className="text-sm font-semibold">Thank You Card</p>
{!open && (
<p className="mt-1 truncate text-xs text-slate-500">
{localSurvey?.thankYouCard?.enabled ? "Shown" : "Hidden"}
</p>
)}
</div>
</div>
{localSurvey.type !== "link" && (
<div className="flex items-center space-x-2">
<Label htmlFor="thank-you-toggle">Show</Label>
<Switch
id="thank-you-toggle"
checked={localSurvey?.thankYouCard?.enabled}
onClick={(e) => {
e.stopPropagation();
updateSurvey({ enabled: !localSurvey.thankYouCard?.enabled });
}}
/>
</div>
)}
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="px-4 pb-6">
<form>
<QuestionFormInput
id="headline"
label="Note*"
value={localSurvey?.thankYouCard?.headline}
localSurvey={localSurvey}
questionIdx={localSurvey.questions.length}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
<QuestionFormInput
id="subheader"
value={localSurvey.thankYouCard.subheader}
label={"Description"}
localSurvey={localSurvey}
questionIdx={localSurvey.questions.length}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
<div className="mt-4">
<div className="flex items-center space-x-1">
<Switch
id="showButton"
checked={showThankYouCardCTA}
onCheckedChange={() => {
if (showThankYouCardCTA) {
updateSurvey({ buttonLabel: undefined, buttonLink: undefined });
} else {
updateSurvey({
buttonLabel: { default: "Create your own Survey" },
buttonLink: "https://formbricks.com/signup",
});
}
setshowThankYouCardCTA(!showThankYouCardCTA);
}}
/>
<Label htmlFor="showButton" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Show Button</h3>
<p className="text-xs font-normal text-slate-500">
Send your respondents to a page of your choice.
</p>
</div>
</Label>
</div>
{showThankYouCardCTA && (
<div className="border-1 mt-4 space-y-4 rounded-md border bg-slate-100 p-4 pt-2">
<div className="space-y-2">
<QuestionFormInput
id="buttonLabel"
label="Button Label"
placeholder="Create your own Survey"
className="bg-white"
value={localSurvey.thankYouCard.buttonLabel}
localSurvey={localSurvey}
questionIdx={localSurvey.questions.length}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
<div className="space-y-2">
<Label>Button Link</Label>
<Input
id="buttonLink"
name="buttonLink"
className="bg-white"
placeholder="https://formbricks.com/signup"
value={localSurvey.thankYouCard.buttonLink}
onChange={(e) => updateSurvey({ buttonLink: e.target.value })}
/>
</div>
</div>
)}
</div>
</form>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
</div>
);
};

View File

@@ -5,8 +5,8 @@ import { usePathname } from "next/navigation";
import { useState } from "react";
import { LocalizedEditor } from "@formbricks/ee/multi-language/components/localized-editor";
import { cn } from "@formbricks/lib/cn";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyWelcomeCard } from "@formbricks/types/surveys/types";
import { FileInput } from "@formbricks/ui/FileInput";
import { Label } from "@formbricks/ui/Label";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
@@ -48,7 +48,7 @@ export const EditWelcomeCard = ({
}
};
const updateSurvey = (data: Partial<TSurvey["welcomeCard"]>) => {
const updateSurvey = (data: Partial<TSurveyWelcomeCard>) => {
setLocalSurvey({
...localSurvey,
welcomeCard: {
@@ -59,11 +59,7 @@ export const EditWelcomeCard = ({
};
return (
<div
className={cn(
open ? "scale-100 shadow-lg" : "scale-97 shadow-md",
"group flex flex-row rounded-lg bg-white transition-transform duration-300 ease-in-out"
)}>
<div className={cn(open ? "shadow-lg" : "shadow-md", "group flex flex-row rounded-lg bg-white")}>
<div
className={cn(
open ? "bg-slate-50" : "",

View File

@@ -0,0 +1,279 @@
"use client";
import { QUESTIONS_ICON_MAP, QUESTIONS_NAME_MAP, getQuestionDefaults } from "@/app/lib/questions";
import { createId } from "@paralleldrive/cuid2";
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react";
import { useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { TProduct } from "@formbricks/types/product";
import {
TSurvey,
TSurveyEndScreenCard,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveyRedirectUrlCard,
ZSurveyQuestion,
} from "@formbricks/types/surveys/types";
import { ConfirmationModal } from "@formbricks/ui/ConfirmationModal";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@formbricks/ui/DropdownMenu";
interface EditorCardMenuProps {
survey: TSurvey;
cardIdx: number;
lastCard: boolean;
duplicateCard: (cardIdx: number) => void;
deleteCard: (cardIdx: number) => void;
moveCard: (cardIdx: number, up: boolean) => void;
card: TSurveyQuestion | TSurveyEndScreenCard | TSurveyRedirectUrlCard;
updateCard: (cardIdx: number, updatedAttributes: any) => void;
addCard: (question: any, index?: number) => void;
cardType: "question" | "ending";
product?: TProduct;
}
export const EditorCardMenu = ({
survey,
cardIdx,
lastCard,
duplicateCard,
deleteCard,
moveCard,
product,
card,
updateCard,
addCard,
cardType,
}: EditorCardMenuProps) => {
const [logicWarningModal, setLogicWarningModal] = useState(false);
const [changeToType, setChangeToType] = useState(
card.type !== "endScreen" && card.type !== "redirectToUrl" ? card.type : undefined
);
const isDeleteDisabled =
cardType === "question"
? survey.questions.length === 1
: survey.type === "link" && survey.endings.length === 1;
const changeQuestionType = (type?: TSurveyQuestionTypeEnum) => {
const parseResult = ZSurveyQuestion.safeParse(card);
if (parseResult.success && type) {
const question = parseResult.data;
const { headline, required, subheader, imageUrl, videoUrl, buttonLabel, backButtonLabel } = question;
const questionDefaults = getQuestionDefaults(type, product);
// if going from single select to multi select or vice versa, we need to keep the choices as well
if (
(type === TSurveyQuestionTypeEnum.MultipleChoiceSingle &&
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) ||
(type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle)
) {
updateCard(cardIdx, {
choices: question.choices,
type,
logic: undefined,
});
return;
}
updateCard(cardIdx, {
...questionDefaults,
type,
headline,
subheader,
required,
imageUrl,
videoUrl,
buttonLabel,
backButtonLabel,
logic: undefined,
});
}
};
const addQuestionCardBelow = (type: TSurveyQuestionTypeEnum) => {
const parseResult = ZSurveyQuestion.safeParse(card);
if (parseResult.success) {
const question = parseResult.data;
const questionDefaults = getQuestionDefaults(type, product);
addCard(
{
...questionDefaults,
type,
id: createId(),
required: true,
},
cardIdx + 1
);
// scroll to the new question
const section = document.getElementById(`${question.id}`);
section?.scrollIntoView({ behavior: "smooth", block: "end", inline: "end" });
}
};
const addEndingCardBelow = () => {
addCard(cardIdx + 1);
};
const onConfirm = () => {
changeQuestionType(changeToType);
setLogicWarningModal(false);
};
return (
<div className="flex space-x-2">
<CopyIcon
className="h-4 cursor-pointer text-slate-500 hover:text-slate-600"
onClick={(e) => {
e.stopPropagation();
duplicateCard(cardIdx);
}}
/>
<TrashIcon
className={cn(
"h-4 cursor-pointer text-slate-500",
isDeleteDisabled ? "cursor-not-allowed opacity-50" : "hover:text-slate-600"
)}
onClick={(e) => {
e.stopPropagation();
if (isDeleteDisabled) return;
deleteCard(cardIdx);
}}
/>
<DropdownMenu>
<DropdownMenuTrigger>
<EllipsisIcon className="h-4 w-4 text-slate-500 hover:text-slate-600" />
</DropdownMenuTrigger>
<DropdownMenuContent className="border border-slate-200">
<div className="flex flex-col">
{cardType === "question" && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer text-sm text-slate-600 hover:text-slate-700">
Change question type
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="ml-2 border border-slate-200 text-slate-600 hover:text-slate-700">
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
const parsedResult = ZSurveyQuestion.safeParse(card);
if (parsedResult.success) {
const question = parsedResult.data;
if (type === question.type) return null;
return (
<DropdownMenuItem
key={type}
className="min-h-8 cursor-pointer"
onClick={() => {
setChangeToType(type as TSurveyQuestionTypeEnum);
if (question.logic) {
setLogicWarningModal(true);
return;
}
changeQuestionType(type as TSurveyQuestionTypeEnum);
}}>
{QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}
<span className="ml-2">{name}</span>
</DropdownMenuItem>
);
}
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{cardType === "ending" && (
<DropdownMenuItem
className="flex min-h-8 cursor-pointer justify-between text-slate-600 hover:text-slate-700"
onClick={(e) => {
e.preventDefault();
addEndingCardBelow();
}}>
<span className="text-sm">Add ending below</span>
</DropdownMenuItem>
)}
{cardType === "question" && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer text-sm text-slate-600 hover:text-slate-700">
Add question below
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="ml-4 border border-slate-200">
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
if (type === card.type) return null;
return (
<DropdownMenuItem
key={type}
className="min-h-8 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
if (cardType === "question") {
addQuestionCardBelow(type as TSurveyQuestionTypeEnum);
}
}}>
{QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}
<span className="ml-2">{name}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuItem
className={`flex min-h-8 cursor-pointer justify-between text-slate-600 hover:text-slate-700 ${
cardIdx === 0 ? "opacity-50" : ""
}`}
onClick={(e) => {
if (cardIdx !== 0) {
e.stopPropagation();
moveCard(cardIdx, true);
}
}}
disabled={cardIdx === 0}>
<span className="text-sm">Move up</span>
<ArrowUpIcon className="h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
className={`flex min-h-8 cursor-pointer justify-between text-slate-600 hover:text-slate-700 ${
lastCard ? "opacity-50" : ""
}`}
onClick={(e) => {
if (!lastCard) {
e.stopPropagation();
moveCard(cardIdx, false);
}
}}
disabled={lastCard}>
<span className="text-sm text-slate-600 hover:text-slate-700">Move down</span>
<ArrowDownIcon className="h-4 w-4" />
</DropdownMenuItem>
</div>
</DropdownMenuContent>
</DropdownMenu>
<ConfirmationModal
open={logicWarningModal}
setOpen={setLogicWarningModal}
title="Changing will cause logic errors"
text="Changing the question type will remove the logic conditions from this question"
buttonText="Change anyway"
onConfirm={onConfirm}
/>
</div>
);
};

View File

@@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyEndScreenCard } from "@formbricks/types/surveys/types";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
import { Switch } from "@formbricks/ui/Switch";
interface EndScreenFormProps {
localSurvey: TSurvey;
endingCardIndex: number;
isInvalid: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
attributeClasses: TAttributeClass[];
updateSurvey: (input: Partial<TSurveyEndScreenCard>) => void;
endingCard: TSurveyEndScreenCard;
}
export const EndScreenForm = ({
localSurvey,
endingCardIndex,
isInvalid,
selectedLanguageCode,
setSelectedLanguageCode,
attributeClasses,
updateSurvey,
endingCard,
}: EndScreenFormProps) => {
const [showEndingCardCTA, setshowEndingCardCTA] = useState<boolean>(
endingCard.type === "endScreen" &&
(!!getLocalizedValue(endingCard.buttonLabel, selectedLanguageCode) || !!endingCard.buttonLink)
);
return (
<form>
<QuestionFormInput
id="headline"
label="Note*"
value={endingCard.headline}
localSurvey={localSurvey}
questionIdx={localSurvey.questions.length + endingCardIndex}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
<QuestionFormInput
id="subheader"
value={endingCard.subheader}
label={"Description"}
localSurvey={localSurvey}
questionIdx={localSurvey.questions.length + endingCardIndex}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
<div className="mt-4">
<div className="flex items-center space-x-1">
<Switch
id="showButton"
checked={showEndingCardCTA}
onCheckedChange={() => {
if (showEndingCardCTA) {
updateSurvey({ buttonLabel: undefined, buttonLink: undefined });
} else {
updateSurvey({
buttonLabel: { default: "Create your own Survey" },
buttonLink: "https://formbricks.com/signup",
});
}
setshowEndingCardCTA(!showEndingCardCTA);
}}
/>
<Label htmlFor="showButton" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Show Button</h3>
<p className="text-xs font-normal text-slate-500">
Send your respondents to a page of your choice.
</p>
</div>
</Label>
</div>
{showEndingCardCTA && (
<div className="border-1 mt-4 space-y-4 rounded-md border bg-slate-100 p-4 pt-2">
<div className="space-y-2">
<QuestionFormInput
id="buttonLabel"
label="Button Label"
placeholder="Create your own Survey"
className="bg-white"
value={endingCard.buttonLabel}
localSurvey={localSurvey}
questionIdx={localSurvey.questions.length + endingCardIndex}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
<div className="space-y-2">
<Label>Button Link</Label>
<Input
id="buttonLink"
name="buttonLink"
className="bg-white"
placeholder="https://formbricks.com/signup"
value={endingCard.buttonLink}
onChange={(e) => updateSurvey({ buttonLink: e.target.value })}
/>
</div>
</div>
)}
</div>
</form>
);
};

View File

@@ -7,7 +7,7 @@ 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/attributeClasses";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/common";
import { TProduct } from "@formbricks/types/product";
import { TSurvey, TSurveyFileUploadQuestion } from "@formbricks/types/surveys/types";

View File

@@ -47,11 +47,7 @@ export const HiddenFieldsCard = ({
};
return (
<div
className={cn(
open ? "scale-100 shadow-lg" : "scale-97 shadow-md",
"group z-10 flex flex-row rounded-lg bg-white transition-transform duration-300 ease-in-out"
)}>
<div className={cn(open ? "shadow-lg" : "shadow-md", "group z-10 flex flex-row rounded-lg bg-white")}>
<div
className={cn(
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
@@ -118,11 +114,13 @@ export const HiddenFieldsCard = ({
onSubmit={(e) => {
e.preventDefault();
const existingQuestionIds = localSurvey.questions.map((question) => question.id);
const existingEndingCardIds = localSurvey.endings.map((ending) => ending.id);
const existingHiddenFieldIds = localSurvey.hiddenFields.fieldIds ?? [];
const validateIdError = validateId(
"Hidden field",
hiddenField,
existingQuestionIds,
existingEndingCardIds,
existingHiddenFieldIds
);

View File

@@ -5,6 +5,7 @@ import { AlertCircleIcon, BlocksIcon, CheckIcon, EarthIcon, LinkIcon, MonitorIco
import Link from "next/link";
import { useEffect, useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { getDefaultEndingCard } from "@formbricks/lib/templates";
import { TEnvironment } from "@formbricks/types/environment";
import { TProduct } from "@formbricks/types/product";
import { TSegment } from "@formbricks/types/segment";
@@ -17,10 +18,17 @@ interface HowToSendCardProps {
localSurvey: TSurvey;
setLocalSurvey: (survey: TSurvey | ((TSurvey: TSurvey) => TSurvey)) => void;
environment: TEnvironment;
organizationId: string;
product: TProduct;
}
export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment, product }: HowToSendCardProps) => {
export const HowToSendCard = ({
localSurvey,
setLocalSurvey,
environment,
product,
organizationId,
}: HowToSendCardProps) => {
const [open, setOpen] = useState(false);
const [appSetupCompleted, setAppSetupCompleted] = useState(false);
const [websiteSetupCompleted, setWebsiteSetupCompleted] = useState(false);
@@ -33,13 +41,14 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment, produc
}, [environment]);
const setSurveyType = (type: TSurveyType) => {
const endingsTemp = localSurvey.endings;
if (type === "link" && localSurvey.endings.length === 0) {
endingsTemp.push(getDefaultEndingCard(localSurvey.languages));
}
setLocalSurvey((prevSurvey) => ({
...prevSurvey,
type,
thankYouCard: {
...prevSurvey.thankYouCard,
enabled: type === "link" ? true : prevSurvey.thankYouCard.enabled,
},
endings: endingsTemp,
}));
// if the type is "app" and the local survey does not already have a segment, we create a new temporary segment
@@ -215,12 +224,18 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment, produc
</RadioGroup>
</div>
{promotedFeaturesString && (
<div className="mt-2 flex items-center space-x-3 rounded-b-lg border border-slate-200 bg-slate-50/50 px-4 py-2">
<AlertCircleIcon className="h-5 w-5 text-slate-500" />
<div className="text-slate-500">
<div className="mt-2 flex items-center space-x-3 rounded-b-lg border border-slate-200 bg-slate-100 px-4 py-2">
🤓
<div className="ml-2 text-slate-500">
<p className="text-xs">
You can also use Formbricks to run {promotedFeaturesString} surveys. Create a new product for
your {promotedFeaturesString} to use this feature.
You can also use Formbricks to run {promotedFeaturesString} surveys.{" "}
<Link
target="_blank"
href={`/organizations/${organizationId}/products/new/channel`}
className="font-medium underline decoration-slate-400 underline-offset-2">
Create a new product
</Link>{" "}
for your {promotedFeaturesString} to use this feature.
</p>
</div>
</div>

View File

@@ -6,12 +6,13 @@ import {
SplitIcon,
TrashIcon,
} from "lucide-react";
import Image from "next/image";
import { useMemo, useState } from "react";
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/attributeClasses";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import {
TSurvey,
TSurveyLogic,
@@ -48,6 +49,39 @@ type LogicConditions = {
};
};
const conditions = {
openText: ["submitted", "skipped"],
multipleChoiceSingle: ["submitted", "skipped", "equals", "notEquals", "includesOne"],
multipleChoiceMulti: ["submitted", "skipped", "includesAll", "includesOne", "equals"],
nps: [
"equals",
"notEquals",
"lessThan",
"lessEqual",
"greaterThan",
"greaterEqual",
"submitted",
"skipped",
],
rating: [
"equals",
"notEquals",
"lessThan",
"lessEqual",
"greaterThan",
"greaterEqual",
"submitted",
"skipped",
],
cta: ["clicked", "skipped"],
consent: ["skipped", "accepted"],
pictureSelection: ["submitted", "skipped", "includesAll", "includesOne", "equals"],
fileUpload: ["uploaded", "notUploaded"],
cal: ["skipped", "booked"],
matrix: ["isCompletelySubmitted", "isPartiallySubmitted", "skipped"],
address: ["submitted", "skipped"],
};
export const LogicEditor = ({
localSurvey,
question,
@@ -56,54 +90,27 @@ export const LogicEditor = ({
attributeClasses,
}: LogicEditorProps) => {
const [searchValue, setSearchValue] = useState<string>("");
localSurvey = useMemo(() => {
const showDropdownSearch = question.type !== "pictureSelection";
const transformedSurvey = useMemo(() => {
return replaceHeadlineRecall(localSurvey, "default", attributeClasses);
}, [localSurvey, attributeClasses]);
const questionValues = useMemo(() => {
const questionValues: string[] = useMemo(() => {
if ("choices" in question) {
return question.choices.map((choice) => getLocalizedValue(choice.label, "default"));
if (question.type === "pictureSelection") {
return question.choices.map((choice) => choice.id);
} else {
return question.choices.map((choice) => getLocalizedValue(choice.label, "default"));
}
} else if ("range" in question) {
return Array.from({ length: question.range ? question.range : 0 }, (_, i) => (i + 1).toString());
} else if (question.type === TSurveyQuestionTypeEnum.NPS) {
return Array.from({ length: 11 }, (_, i) => (i + 0).toString());
}
return [];
}, [question]);
const conditions = {
openText: ["submitted", "skipped"],
multipleChoiceSingle: ["submitted", "skipped", "equals", "notEquals", "includesOne"],
multipleChoiceMulti: ["submitted", "skipped", "includesAll", "includesOne", "equals"],
nps: [
"equals",
"notEquals",
"lessThan",
"lessEqual",
"greaterThan",
"greaterEqual",
"submitted",
"skipped",
],
rating: [
"equals",
"notEquals",
"lessThan",
"lessEqual",
"greaterThan",
"greaterEqual",
"submitted",
"skipped",
],
cta: ["clicked", "skipped"],
consent: ["skipped", "accepted"],
pictureSelection: ["submitted", "skipped"],
fileUpload: ["uploaded", "notUploaded"],
cal: ["skipped", "booked"],
matrix: ["isCompletelySubmitted", "isPartiallySubmitted", "skipped"],
address: ["submitted", "skipped"],
};
const logicConditions: LogicConditions = {
submitted: {
label: "is submitted",
@@ -270,13 +277,34 @@ export const LogicEditor = ({
return <></>;
}
const getLogicDisplayValue = (value: string | string[]) => {
if (Array.isArray(value)) {
const getLogicDisplayValue = (value: string | string[]): string => {
if (question.type === "pictureSelection") {
if (Array.isArray(value)) {
return value
.map((val) => {
const choiceIndex = question.choices.findIndex((choice) => choice.id === val);
return `Picture ${choiceIndex + 1}`;
})
.join(", ");
} else {
const choiceIndex = question.choices.findIndex((choice) => choice.id === value);
return `Picture ${choiceIndex + 1}`;
}
} else if (Array.isArray(value)) {
return value.join(", ");
}
return value;
};
const getOptionPreview = (value: string) => {
if (question.type === "pictureSelection") {
const choice = question.choices.find((choice) => choice.id === value);
if (choice) {
return <Image src={choice.imageUrl} alt={"Picture"} width={20} height={12} className="rounded-xs" />;
}
}
};
return (
<div className="mt-3">
<Label>Logic Jumps</Label>
@@ -330,14 +358,16 @@ export const LogicEditor = ({
className="w-40 bg-slate-50 text-slate-700"
align="start"
side="bottom">
<Input
autoFocus
placeholder="Search options"
className="mb-1 w-full bg-white"
onChange={(e) => setSearchValue(e.target.value)}
value={searchValue}
onKeyDown={(e) => e.stopPropagation()}
/>
{showDropdownSearch && (
<Input
autoFocus
placeholder="Search options"
className="mb-1 w-full bg-white"
onChange={(e) => setSearchValue(e.target.value)}
value={searchValue}
onKeyDown={(e) => e.stopPropagation()}
/>
)}
<div className="max-h-72 overflow-y-auto overflow-x-hidden">
{logicConditions[logic.condition].values
?.filter((value) => value.includes(searchValue))
@@ -356,7 +386,10 @@ export const LogicEditor = ({
? updateLogic(logicIdx, { value })
: updateMultiSelectLogic(logicIdx, e, value)
}>
{value}
<div className="flex space-x-2">
{question.type === "pictureSelection" && getOptionPreview(value)}
<p>{getLogicDisplayValue(value)}</p>
</div>
</DropdownMenuCheckboxItem>
))}
</div>
@@ -373,7 +406,7 @@ export const LogicEditor = ({
<SelectValue placeholder="Select question" />
</SelectTrigger>
<SelectContent>
{localSurvey.questions.map(
{transformedSurvey.questions.map(
(question, idx) =>
idx !== questionIdx && (
<SelectItem
@@ -390,7 +423,15 @@ export const LogicEditor = ({
</SelectItem>
)
)}
<SelectItem value="end">End of survey</SelectItem>
{localSurvey.endings.map((ending) => {
return (
<SelectItem value={ending.id}>
{ending.type === "endScreen"
? getLocalizedValue(ending.headline, "default")
: ending.label}
</SelectItem>
);
})}
</SelectContent>
</Select>
<div>

View File

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

View File

@@ -7,7 +7,7 @@ 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/attributeClasses";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import {
TI18nString,
TShuffleOption,

View File

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

View File

@@ -2,7 +2,7 @@
import { HashIcon, LinkIcon, MailIcon, MessageSquareTextIcon, PhoneIcon, PlusIcon } from "lucide-react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import {
TSurvey,
TSurveyOpenTextQuestion,
@@ -10,8 +10,8 @@ import {
} from "@formbricks/types/surveys/types";
import { Button } from "@formbricks/ui/Button";
import { Label } from "@formbricks/ui/Label";
import { OptionsSwitch } from "@formbricks/ui/OptionsSwitch";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
import { OptionsSwitcher } from "@formbricks/ui/QuestionTypeSelector";
const questionTypes = [
{ value: "text", label: "Text", icon: <MessageSquareTextIcon className="h-4 w-4" /> },
@@ -127,10 +127,10 @@ export const OpenQuestionForm = ({
<div className="mt-3">
<Label htmlFor="questionType">Input Type</Label>
<div className="mt-2 flex items-center">
<OptionsSwitcher
<OptionsSwitch
options={questionTypes}
currentOption={question.inputType}
handleTypeChange={handleInputChange} // Use the merged function
handleOptionChange={handleInputChange} // Use the merged function
/>
</div>
</div>

View File

@@ -2,7 +2,7 @@ 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/attributeClasses";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyPictureSelectionQuestion } from "@formbricks/types/surveys/types";
import { Button } from "@formbricks/ui/Button";
import { FileInput } from "@formbricks/ui/FileInput";
@@ -35,6 +35,48 @@ export const PictureSelectionForm = ({
const environmentId = localSurvey.environmentId;
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
const handleChoiceDeletion = (choiceValue: string) => {
// Filter out the deleted choice from the choices array
const newChoices = question.choices?.filter((choice) => choice.id !== choiceValue) || [];
// Update the logic, removing the deleted choice value
const newLogic =
question.logic?.map((logic) => {
let updatedValue = logic.value;
if (Array.isArray(logic.value)) {
updatedValue = logic.value.filter((value) => value !== choiceValue);
} else if (logic.value === choiceValue) {
updatedValue = undefined;
}
return { ...logic, value: updatedValue };
}) || [];
// Update the question with new choices and logic
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
};
const handleFileInputChanges = (urls: string[]) => {
// Handle choice deletion
if (urls.length < question.choices.length) {
const deletedChoice = question.choices.find((choice) => !urls.includes(choice.imageUrl));
if (deletedChoice) {
handleChoiceDeletion(deletedChoice.id);
}
}
// Handle choice addition
const updatedChoices = urls.map((url) => {
const existingChoice = question.choices.find((choice) => choice.imageUrl === url);
return existingChoice ? { ...existingChoice } : { imageUrl: url, id: createId() };
});
updateQuestion(questionIdx, {
choices: updatedChoices,
});
};
return (
<form>
<QuestionFormInput
@@ -99,11 +141,7 @@ export const PictureSelectionForm = ({
id="choices-file-input"
allowedFileExtensions={["png", "jpeg", "jpg"]}
environmentId={environmentId}
onFileUpload={(urls: string[]) => {
updateQuestion(questionIdx, {
choices: urls.map((url) => ({ imageUrl: url, id: createId() })),
});
}}
onFileUpload={handleFileInputChanges}
fileUrl={question?.choices?.map((choice) => choice.imageUrl)}
multiple={true}
/>

View File

@@ -1,5 +1,6 @@
"use client";
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util";
import { QUESTIONS_ICON_MAP, getTSurveyQuestionTypeEnumName } from "@/app/lib/questions";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
@@ -8,7 +9,7 @@ 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/attributeClasses";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TProduct } from "@formbricks/types/product";
import {
TI18nString,
@@ -25,13 +26,13 @@ import { CTAQuestionForm } from "./CTAQuestionForm";
import { CalQuestionForm } from "./CalQuestionForm";
import { ConsentQuestionForm } from "./ConsentQuestionForm";
import { DateQuestionForm } from "./DateQuestionForm";
import { EditorCardMenu } from "./EditorCardMenu";
import { FileUploadQuestionForm } from "./FileUploadQuestionForm";
import { MatrixQuestionForm } from "./MatrixQuestionForm";
import { MultipleChoiceQuestionForm } from "./MultipleChoiceQuestionForm";
import { NPSQuestionForm } from "./NPSQuestionForm";
import { OpenQuestionForm } from "./OpenQuestionForm";
import { PictureSelectionForm } from "./PictureSelectionForm";
import { QuestionMenu } from "./QuestionMenu";
import { RatingQuestionForm } from "./RatingQuestionForm";
interface QuestionCardProps {
@@ -80,25 +81,6 @@ export const QuestionCard = ({
const open = activeQuestionId === question.id;
const [openAdvanced, setOpenAdvanced] = useState(question.logic && question.logic.length > 0);
// formats the text to highlight specific parts of the text with slashes
const formatTextWithSlashes = (text) => {
const regex = /\/(.*?)\\/g;
const parts = text.split(regex);
return parts.map((part, index) => {
// Check if the part was inside slashes
if (index % 2 !== 0) {
return (
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-xs">
{part}
</span>
);
} else {
return part;
}
});
};
const updateEmptyNextButtonLabels = (labelValue: TI18nString) => {
localSurvey.questions.forEach((q, index) => {
if (index === localSurvey.questions.length - 1) return;
@@ -140,8 +122,8 @@ export const QuestionCard = ({
return (
<div
className={cn(
open ? "scale-100 shadow-lg" : "scale-97 shadow-md",
"flex w-full flex-row rounded-lg bg-white transition-all duration-300 ease-in-out"
open ? "shadow-lg" : "shadow-md",
"flex w-full flex-row rounded-lg bg-white duration-300"
)}
ref={setNodeRef}
style={style}
@@ -151,11 +133,11 @@ export const QuestionCard = ({
{...attributes}
className={cn(
open ? "bg-slate-700" : "bg-slate-400",
"top-0 w-[5%] rounded-l-lg p-2 text-center text-sm text-white hover:cursor-grab hover:bg-slate-600",
"top-0 w-10 rounded-l-lg p-2 text-center text-sm text-white hover:cursor-grab hover:bg-slate-600",
isInvalid && "bg-red-400 hover:bg-red-600",
"flex flex-col items-center justify-between"
)}>
<span>{questionIdx + 1}</span>
<div className="mt-3 flex w-full justify-center">{QUESTIONS_ICON_MAP[question.type]}</div>
<button className="opacity-0 hover:cursor-move group-hover:opacity-100">
<GripIcon className="h-4 w-4" />
@@ -173,12 +155,15 @@ export const QuestionCard = ({
className="w-[95%] flex-1 rounded-r-lg border border-slate-200">
<Collapsible.CollapsibleTrigger
asChild
className={cn(open ? "" : " ", "flex cursor-pointer justify-between gap-4 p-4 hover:bg-slate-50")}>
className={cn(
open ? "" : " ",
"flex cursor-pointer justify-between gap-4 rounded-r-lg p-4 hover:bg-slate-50"
)}>
<div>
<div className="flex grow">
<div className="-ml-0.5 mr-3 h-6 min-w-[1.5rem] text-slate-400">
{/* <div className="-ml-0.5 mr-3 h-6 min-w-[1.5rem] text-slate-400">
{QUESTIONS_ICON_MAP[question.type]}
</div>
</div> */}
<div className="grow" dir="auto">
<p className="text-sm font-semibold">
{recallToHeadline(
@@ -199,23 +184,27 @@ export const QuestionCard = ({
)
: getTSurveyQuestionTypeEnumName(question.type)}
</p>
{!open && question?.required && (
<p className="mt-1 truncate text-xs text-slate-500">{question?.required && "Required"}</p>
{!open && (
<p className="mt-1 truncate text-xs text-slate-500">
{question?.required ? "Required" : "Optional"}
</p>
)}
</div>
</div>
<div className="flex items-center space-x-2">
<QuestionMenu
questionIdx={questionIdx}
lastQuestion={lastQuestion}
duplicateQuestion={duplicateQuestion}
deleteQuestion={deleteQuestion}
moveQuestion={moveQuestion}
question={question}
<EditorCardMenu
survey={localSurvey}
cardIdx={questionIdx}
lastCard={lastQuestion}
duplicateCard={duplicateQuestion}
deleteCard={deleteQuestion}
moveCard={moveQuestion}
card={question}
product={product}
updateQuestion={updateQuestion}
addQuestion={addQuestion}
updateCard={updateQuestion}
addCard={addQuestion}
cardType="question"
/>
</div>
</div>

View File

@@ -1,231 +0,0 @@
"use client";
import { QUESTIONS_ICON_MAP, QUESTIONS_NAME_MAP, getQuestionDefaults } from "@/app/lib/questions";
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 { ConfirmationModal } from "@formbricks/ui/ConfirmationModal";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@formbricks/ui/DropdownMenu";
interface QuestionMenuProps {
questionIdx: number;
lastQuestion: boolean;
duplicateQuestion: (questionIdx: number) => void;
deleteQuestion: (questionIdx: number) => void;
moveQuestion: (questionIdx: number, up: boolean) => void;
question: TSurveyQuestion;
product: TProduct;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
addQuestion: (question: any, index?: number) => void;
}
export const QuestionMenu = ({
questionIdx,
lastQuestion,
duplicateQuestion,
deleteQuestion,
moveQuestion,
product,
question,
updateQuestion,
addQuestion,
}: QuestionMenuProps) => {
const [logicWarningModal, setLogicWarningModal] = useState(false);
const [changeToType, setChangeToType] = useState(question.type);
const changeQuestionType = (type: TSurveyQuestionTypeEnum) => {
const { headline, required, subheader, imageUrl, videoUrl, buttonLabel, backButtonLabel } = question;
const questionDefaults = getQuestionDefaults(type, product);
// if going from single select to multi select or vice versa, we need to keep the choices as well
if (
(type === TSurveyQuestionTypeEnum.MultipleChoiceSingle &&
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) ||
(type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle)
) {
updateQuestion(questionIdx, {
choices: question.choices,
type,
logic: undefined,
});
return;
}
updateQuestion(questionIdx, {
...questionDefaults,
type,
headline,
subheader,
required,
imageUrl,
videoUrl,
buttonLabel,
backButtonLabel,
logic: undefined,
});
};
const addQuestionBelow = (type: TSurveyQuestionTypeEnum) => {
const questionDefaults = getQuestionDefaults(type, product);
addQuestion(
{
...questionDefaults,
type,
id: createId(),
required: true,
},
questionIdx + 1
);
// scroll to the new question
const section = document.getElementById(`${question.id}`);
section?.scrollIntoView({ behavior: "smooth", block: "end", inline: "end" });
};
const onConfirm = () => {
changeQuestionType(changeToType);
setLogicWarningModal(false);
};
return (
<div className="flex space-x-2">
<CopyIcon
className="h-4 cursor-pointer text-slate-500 hover:text-slate-600"
onClick={(e) => {
e.stopPropagation();
duplicateQuestion(questionIdx);
}}
/>
<TrashIcon
className="h-4 cursor-pointer text-slate-500 hover:text-slate-600"
onClick={(e) => {
e.stopPropagation();
deleteQuestion(questionIdx);
}}
/>
<DropdownMenu>
<DropdownMenuTrigger>
<EllipsisIcon className="h-4 w-4 text-slate-500 hover:text-slate-600" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="flex flex-col">
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<div className="cursor-pointer text-slate-500 hover:text-slate-600">
<span className="text-xs text-slate-500">Change question type</span>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="ml-4 border border-slate-200">
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
if (type === question.type) return null;
return (
<DropdownMenuItem
key={type}
className="min-h-8 cursor-pointer text-slate-500"
onClick={() => {
setChangeToType(type as TSurveyQuestionTypeEnum);
if (question.logic) {
setLogicWarningModal(true);
return;
}
changeQuestionType(type as TSurveyQuestionTypeEnum);
}}>
{QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}
<span className="ml-2">{name}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<div className="cursor-pointer text-slate-500 hover:text-slate-600">
<span className="text-xs text-slate-500">Add question below</span>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="ml-4 border border-slate-200">
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
if (type === question.type) return null;
return (
<DropdownMenuItem
key={type}
className="min-h-8 cursor-pointer text-slate-500"
onClick={(e) => {
e.stopPropagation();
addQuestionBelow(type as TSurveyQuestionTypeEnum);
}}>
{QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}
<span className="ml-2">{name}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem
className={`flex min-h-8 cursor-pointer justify-between text-slate-500 hover:text-slate-600 ${
questionIdx === 0 ? "opacity-50" : ""
}`}
onClick={(e) => {
if (questionIdx !== 0) {
e.stopPropagation();
moveQuestion(questionIdx, true);
}
}}
disabled={questionIdx === 0}>
<span className="text-xs text-slate-500">Move up</span>
<ArrowUpIcon className="h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
className={`flex min-h-8 cursor-pointer justify-between text-slate-500 hover:text-slate-600 ${
lastQuestion ? "opacity-50" : ""
}`}
onClick={(e) => {
if (!lastQuestion) {
e.stopPropagation();
moveQuestion(questionIdx, false);
}
}}
disabled={lastQuestion}>
<span className="text-xs text-slate-500">Move down</span>
<ArrowDownIcon className="h-4 w-4" />
</DropdownMenuItem>
</div>
</DropdownMenuContent>
</DropdownMenu>
<ConfirmationModal
open={logicWarningModal}
setOpen={setLogicWarningModal}
title="Changing will cause logic errors"
text="Changing the question type will remove the logic conditions from this question"
buttonText="Change anyway"
onConfirm={onConfirm}
buttonVariant="darkCTA"
/>
</div>
);
};

View File

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

View File

@@ -1,5 +1,6 @@
"use client";
import { AddEndingCardButton } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddEndingCardButton";
import {
DndContext,
DragEndEvent,
@@ -8,20 +9,28 @@ import {
useSensor,
useSensors,
} from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { createId } from "@paralleldrive/cuid2";
import React, { SetStateAction, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { MultiLanguageCard } from "@formbricks/ee/multi-language/components/multi-language-card";
import { extractLanguageCodes, getLocalizedValue, translateQuestion } from "@formbricks/lib/i18n/utils";
import { addMultiLanguageLabels, extractLanguageCodes, getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { getDefaultEndingCard } from "@formbricks/lib/templates";
import { checkForEmptyFallBackValue, extractRecallInfo } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
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 {
isEndingCardValid,
isWelcomeCardValid,
validateQuestion,
validateSurveyQuestionsInBatch,
} from "../lib/validation";
import { AddQuestionButton } from "./AddQuestionButton";
import { EditThankYouCard } from "./EditThankYouCard";
import { EditEndingCard } from "./EditEndingCard";
import { EditWelcomeCard } from "./EditWelcomeCard";
import { HiddenFieldsCard } from "./HiddenFieldsCard";
import { QuestionsDroppable } from "./QuestionsDroppable";
@@ -39,6 +48,7 @@ interface QuestionsViewProps {
isMultiLanguageAllowed?: boolean;
isFormbricksCloud: boolean;
attributeClasses: TAttributeClass[];
plan: TOrganizationBillingPlan;
}
export const QuestionsView = ({
@@ -54,6 +64,7 @@ export const QuestionsView = ({
isMultiLanguageAllowed,
isFormbricksCloud,
attributeClasses,
plan,
}: QuestionsViewProps) => {
const internalQuestionIdMap = useMemo(() => {
return localSurvey.questions.reduce((acc, question) => {
@@ -82,6 +93,36 @@ export const QuestionsView = ({
return survey;
};
useEffect(() => {
if (!invalidQuestions) return;
let updatedInvalidQuestions: string[] = invalidQuestions;
// Check welcome card
if (localSurvey.welcomeCard.enabled && !isWelcomeCardValid(localSurvey.welcomeCard, surveyLanguages)) {
if (!updatedInvalidQuestions.includes("start")) {
updatedInvalidQuestions.push("start");
}
} else {
updatedInvalidQuestions = updatedInvalidQuestions.filter((questionId) => questionId !== "start");
}
// Check thank you card
localSurvey.endings.forEach((ending) => {
if (!isEndingCardValid(ending, surveyLanguages)) {
if (!updatedInvalidQuestions.includes(ending.id)) {
updatedInvalidQuestions.push(ending.id);
}
} else {
updatedInvalidQuestions = updatedInvalidQuestions.filter((questionId) => questionId !== ending.id);
}
});
if (JSON.stringify(updatedInvalidQuestions) !== JSON.stringify(invalidQuestions)) {
setInvalidQuestions(updatedInvalidQuestions);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSurvey.languages, localSurvey.endings, localSurvey.welcomeCard]);
// function to validate individual questions
const validateSurveyQuestion = (question: TSurveyQuestion) => {
// prevent this function to execute further if user hasnt still tried to save the survey
@@ -111,7 +152,7 @@ export const QuestionsView = ({
const updateQuestion = (questionIdx: number, updatedAttributes: any) => {
let updatedSurvey = { ...localSurvey };
if ("id" in updatedAttributes) {
// if the survey whose id is to be changed is linked to logic of any other survey then changing it
// if the survey question whose id is to be changed is linked to logic of any other survey then changing it
const initialQuestionId = updatedSurvey.questions[questionIdx].id;
updatedSurvey = handleQuestionLogicChange(updatedSurvey, initialQuestionId, updatedAttributes.id);
if (invalidQuestions?.includes(initialQuestionId)) {
@@ -181,14 +222,15 @@ export const QuestionsView = ({
}
});
updatedSurvey.questions.splice(questionIdx, 1);
updatedSurvey = handleQuestionLogicChange(updatedSurvey, questionId, "end");
updatedSurvey = handleQuestionLogicChange(updatedSurvey, questionId, "");
const firstEndingCard = localSurvey.endings[0];
setLocalSurvey(updatedSurvey);
delete internalQuestionIdMap[questionId];
if (questionId === activeQuestionIdTemp) {
if (questionIdx <= localSurvey.questions.length && localSurvey.questions.length > 0) {
setActiveQuestionId(localSurvey.questions[questionIdx % localSurvey.questions.length].id);
} else if (localSurvey.thankYouCard.enabled) {
setActiveQuestionId("end");
} else if (firstEndingCard) {
setActiveQuestionId(firstEndingCard.id);
}
}
toast.success("Question deleted.");
@@ -216,18 +258,19 @@ export const QuestionsView = ({
toast.success("Question duplicated.");
};
const addQuestion = (question: any, index?: number) => {
const addQuestion = (question: TSurveyQuestion, index?: number) => {
const updatedSurvey = { ...localSurvey };
if (backButtonLabel) {
question.backButtonLabel = backButtonLabel;
}
const languageSymbols = extractLanguageCodes(localSurvey.languages);
const translatedQuestion = translateQuestion(question, languageSymbols);
const updatedQuestion = addMultiLanguageLabels(question, languageSymbols);
if (index) {
updatedSurvey.questions.splice(index, 0, { ...translatedQuestion, isDraft: true });
updatedSurvey.questions.splice(index, 0, { ...updatedQuestion, isDraft: true });
} else {
updatedSurvey.questions.push({ ...translatedQuestion, isDraft: true });
updatedSurvey.questions.push({ ...updatedQuestion, isDraft: true });
}
setLocalSurvey(updatedSurvey);
@@ -235,6 +278,15 @@ export const QuestionsView = ({
internalQuestionIdMap[question.id] = createId();
};
const addEndingCard = (index: number) => {
const updatedSurvey = structuredClone(localSurvey);
const newEndingCard = getDefaultEndingCard(localSurvey.languages);
updatedSurvey.endings.splice(index, 0, newEndingCard);
setLocalSurvey(updatedSurvey);
};
const moveQuestion = (questionIndex: number, up: boolean) => {
const newQuestions = Array.from(localSurvey.questions);
const [reorderedQuestion] = newQuestions.splice(questionIndex, 1);
@@ -244,29 +296,6 @@ export const QuestionsView = ({
setLocalSurvey(updatedSurvey);
};
useEffect(() => {
if (invalidQuestions === null) return;
const updateInvalidQuestions = (card, cardId, currentInvalidQuestions) => {
if (card.enabled && !isCardValid(card, cardId, surveyLanguages)) {
return currentInvalidQuestions.includes(cardId)
? currentInvalidQuestions
: [...currentInvalidQuestions, cardId];
}
return currentInvalidQuestions.filter((id) => id !== cardId);
};
const updatedQuestionsStart = updateInvalidQuestions(localSurvey.welcomeCard, "start", invalidQuestions);
const updatedQuestionsEnd = updateInvalidQuestions(
localSurvey.thankYouCard,
"end",
updatedQuestionsStart
);
setInvalidQuestions(updatedQuestionsEnd);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSurvey.welcomeCard, localSurvey.thankYouCard]);
//useEffect to validate survey when changes are made to languages
useEffect(() => {
if (!invalidQuestions) return;
@@ -281,29 +310,11 @@ export const QuestionsView = ({
);
});
// Check welcome card
if (localSurvey.welcomeCard.enabled && !isCardValid(localSurvey.welcomeCard, "start", surveyLanguages)) {
if (!updatedInvalidQuestions.includes("start")) {
updatedInvalidQuestions.push("start");
}
} else {
updatedInvalidQuestions = updatedInvalidQuestions.filter((questionId) => questionId !== "start");
}
// Check thank you card
if (localSurvey.thankYouCard.enabled && !isCardValid(localSurvey.thankYouCard, "end", surveyLanguages)) {
if (!updatedInvalidQuestions.includes("end")) {
updatedInvalidQuestions.push("end");
}
} else {
updatedInvalidQuestions = updatedInvalidQuestions.filter((questionId) => questionId !== "end");
}
if (JSON.stringify(updatedInvalidQuestions) !== JSON.stringify(invalidQuestions)) {
setInvalidQuestions(updatedInvalidQuestions);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSurvey.languages, localSurvey.questions]);
}, [localSurvey.languages, localSurvey.questions, localSurvey.endings, localSurvey.welcomeCard]);
useEffect(() => {
const questionWithEmptyFallback = checkForEmptyFallBackValue(localSurvey, selectedLanguageCode);
@@ -324,7 +335,7 @@ export const QuestionsView = ({
})
);
const onDragEnd = (event: DragEndEvent) => {
const onQuestionCardDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
const newQuestions = Array.from(localSurvey.questions);
@@ -336,6 +347,17 @@ export const QuestionsView = ({
setLocalSurvey(updatedSurvey);
};
const onEndingCardDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
const newEndings = Array.from(localSurvey.endings);
const sourceIndex = newEndings.findIndex((ending) => ending.id === active.id);
const destinationIndex = newEndings.findIndex((ending) => ending.id === over?.id);
const [reorderedEndings] = newEndings.splice(sourceIndex, 1);
newEndings.splice(destinationIndex, 0, reorderedEndings);
const updatedSurvey = { ...localSurvey, endings: newEndings };
setLocalSurvey(updatedSurvey);
};
return (
<div className="mt-16 w-full px-5 py-4">
<div className="mb-5 flex w-full flex-col gap-5">
@@ -351,7 +373,7 @@ export const QuestionsView = ({
/>
</div>
<DndContext sensors={sensors} onDragEnd={onDragEnd} collisionDetection={closestCorners}>
<DndContext sensors={sensors} onDragEnd={onQuestionCardDragEnd} collisionDetection={closestCorners}>
<QuestionsDroppable
localSurvey={localSurvey}
product={product}
@@ -373,16 +395,37 @@ export const QuestionsView = ({
<AddQuestionButton addQuestion={addQuestion} product={product} />
<div className="mt-5 flex flex-col gap-5">
<EditThankYouCard
<hr className="border-t border-dashed" />
<DndContext sensors={sensors} onDragEnd={onEndingCardDragEnd} collisionDetection={closestCorners}>
<SortableContext items={localSurvey.endings} strategy={verticalListSortingStrategy}>
{localSurvey.endings.map((ending, index) => {
return (
<EditEndingCard
key={ending.id}
localSurvey={localSurvey}
endingCardIndex={index}
setLocalSurvey={setLocalSurvey}
setActiveQuestionId={setActiveQuestionId}
activeQuestionId={activeQuestionId}
isInvalid={invalidQuestions ? invalidQuestions.includes(ending.id) : false}
setSelectedLanguageCode={setSelectedLanguageCode}
selectedLanguageCode={selectedLanguageCode}
attributeClasses={attributeClasses}
plan={plan}
addEndingCard={addEndingCard}
isFormbricksCloud={isFormbricksCloud}
/>
);
})}
</SortableContext>
</DndContext>
<AddEndingCardButton
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setActiveQuestionId={setActiveQuestionId}
activeQuestionId={activeQuestionId}
isInvalid={invalidQuestions ? invalidQuestions.includes("end") : false}
setSelectedLanguageCode={setSelectedLanguageCode}
selectedLanguageCode={selectedLanguageCode}
attributeClasses={attributeClasses}
addEndingCard={addEndingCard}
/>
<hr />
<HiddenFieldsCard
localSurvey={localSurvey}

View File

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

View File

@@ -0,0 +1,38 @@
import React from "react";
import { TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
interface RedirectUrlFormProps {
endingCard: TSurveyRedirectUrlCard;
updateSurvey: (input: Partial<TSurveyRedirectUrlCard>) => void;
}
export const RedirectUrlForm = ({ endingCard, updateSurvey }: RedirectUrlFormProps) => {
return (
<form className="mt-3 space-y-3">
<div className="space-y-2">
<Label>URL</Label>
<Input
id="redirectUrl"
name="redirectUrl"
className="bg-white"
placeholder="https://formbricks.com/signup"
value={endingCard.url}
onChange={(e) => updateSurvey({ url: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Label</Label>
<Input
id="redirectUrlLabel"
name="redirectUrlLabel"
className="bg-white"
placeholder="Formbricks App"
value={endingCard.label}
onChange={(e) => updateSurvey({ label: e.target.value })}
/>
</div>
</form>
);
};

View File

@@ -26,11 +26,9 @@ export const ResponseOptionsCard = ({
}: ResponseOptionsCardProps) => {
const [open, setOpen] = useState(localSurvey.type === "link" ? true : false);
const autoComplete = localSurvey.autoComplete !== null;
const [redirectToggle, setRedirectToggle] = useState(false);
const [runOnDateToggle, setRunOnDateToggle] = useState(false);
const [closeOnDateToggle, setCloseOnDateToggle] = useState(false);
useState;
const [redirectUrl, setRedirectUrl] = useState<string | null>("");
const [surveyClosedMessageToggle, setSurveyClosedMessageToggle] = useState(false);
const [verifyEmailToggle, setVerifyEmailToggle] = useState(false);
@@ -45,10 +43,6 @@ export const ResponseOptionsCard = ({
});
const [singleUseEncryption, setSingleUseEncryption] = useState(true);
const [verifyEmailSurveyDetails, setVerifyEmailSurveyDetails] = useState({
name: "",
subheading: "",
});
const [runOnDate, setRunOnDate] = useState<Date | null>(null);
const [closeOnDate, setCloseOnDate] = useState<Date | null>(null);
@@ -56,15 +50,6 @@ export const ResponseOptionsCard = ({
const [verifyProtectWithPinError, setVerifyProtectWithPinError] = useState<string | null>(null);
const handleRedirectCheckMark = () => {
setRedirectToggle((prev) => !prev);
if (redirectToggle && localSurvey.redirectUrl) {
setRedirectUrl(null);
setLocalSurvey({ ...localSurvey, redirectUrl: null });
}
};
const handleRunOnDateToggle = () => {
if (runOnDateToggle) {
setRunOnDateToggle(false);
@@ -116,11 +101,6 @@ export const ResponseOptionsCard = ({
if (exceptThisSymbols.includes(e.key)) e.preventDefault();
};
const handleRedirectUrlChange = (link: string) => {
setRedirectUrl(link);
setLocalSurvey({ ...localSurvey, redirectUrl: link });
};
const handleCloseSurveyMessageToggle = () => {
setSurveyClosedMessageToggle((prev) => !prev);
@@ -130,11 +110,8 @@ export const ResponseOptionsCard = ({
};
const handleVerifyEmailToogle = () => {
setVerifyEmailToggle((prev) => !prev);
if (verifyEmailToggle && localSurvey.verifyEmail) {
setLocalSurvey({ ...localSurvey, verifyEmail: null });
}
setVerifyEmailToggle(!verifyEmailToggle);
setLocalSurvey({ ...localSurvey, isVerifyEmailEnabled: !localSurvey.isVerifyEmailEnabled });
};
const handleRunOnDateChange = (date: Date) => {
@@ -219,28 +196,7 @@ export const ResponseOptionsCard = ({
}
};
const handleVerifyEmailSurveyDetailsChange = ({
name,
subheading,
}: {
name?: string;
subheading?: string;
}) => {
const message = {
name: name || verifyEmailSurveyDetails.name,
subheading: subheading || verifyEmailSurveyDetails.subheading,
};
setVerifyEmailSurveyDetails(message);
setLocalSurvey({ ...localSurvey, verifyEmail: message });
};
useEffect(() => {
if (localSurvey.redirectUrl) {
setRedirectUrl(localSurvey.redirectUrl);
setRedirectToggle(true);
}
if (!!localSurvey.surveyClosedMessage) {
setSurveyClosedMessage({
heading: localSurvey.surveyClosedMessage.heading ?? surveyClosedMessage.heading,
@@ -257,11 +213,7 @@ export const ResponseOptionsCard = ({
setSingleUseEncryption(localSurvey.singleUse.isEncrypted);
}
if (localSurvey.verifyEmail) {
setVerifyEmailSurveyDetails({
name: localSurvey.verifyEmail.name!,
subheading: localSurvey.verifyEmail.subheading!,
});
if (localSurvey.isVerifyEmailEnabled) {
setVerifyEmailToggle(true);
}
@@ -389,26 +341,6 @@ export const ResponseOptionsCard = ({
</div>
</AdvancedOptionToggle>
{/* Redirect on completion */}
<AdvancedOptionToggle
htmlId="redirectUrl"
isChecked={redirectToggle}
onToggle={handleRedirectCheckMark}
title="Redirect on completion"
description="Redirect user to link destination when they completed the survey"
childBorder={true}>
<div className="w-full p-4">
<Input
autoFocus
className="w-full bg-white"
type="url"
placeholder="https://www.example.com"
value={redirectUrl ? redirectUrl : ""}
onChange={(e) => handleRedirectUrlChange(e.target.value)}
/>
</div>
</AdvancedOptionToggle>
{localSurvey.type === "link" && (
<>
{/* Adjust Survey Closed Message */}
@@ -518,36 +450,8 @@ export const ResponseOptionsCard = ({
onToggle={handleVerifyEmailToogle}
title="Verify email before submission"
description="Only let people with a real email respond."
childBorder={true}>
<div className="flex w-full items-center space-x-1 p-4 pb-4">
<div className="w-full cursor-pointer items-center bg-slate-50">
<Label htmlFor="howItWorks">How it works</Label>
<p className="mb-4 mt-2 text-sm text-slate-500">
Respondants will receive the survey link via email.
</p>
<Label htmlFor="headline">Survey Name (Public)</Label>
<Input
autoFocus
id="heading"
className="mb-4 mt-2 bg-white"
name="heading"
placeholder="Job Application Form"
defaultValue={verifyEmailSurveyDetails.name}
onChange={(e) => handleVerifyEmailSurveyDetailsChange({ name: e.target.value })}
/>
<Label htmlFor="headline">Subheader (Public)</Label>
<Input
className="mt-2 bg-white"
id="subheading"
name="subheading"
placeholder="Thanks for applying as a full stack engineer"
defaultValue={verifyEmailSurveyDetails.subheading}
onChange={(e) => handleVerifyEmailSurveyDetailsChange({ subheading: e.target.value })}
/>
</div>
</div>
</AdvancedOptionToggle>
childBorder={true}
/>
<AdvancedOptionToggle
htmlId="protectSurveyWithPin"
isChecked={isPinProtectionEnabled}

View File

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

View File

@@ -3,7 +3,7 @@ import { CSS } from "@dnd-kit/utilities";
import { GripVerticalIcon, PlusIcon, TrashIcon } from "lucide-react";
import { cn } from "@formbricks/lib/cn";
import { createI18nString } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import {
TI18nString,
TSurvey,

View File

@@ -1,6 +1,6 @@
import { AdvancedTargetingCard } from "@formbricks/ee/advanced-targeting/components/advanced-targeting-card";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TActionClass } from "@formbricks/types/action-classes";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { TMembershipRole } from "@formbricks/types/memberships";
import { TProduct } from "@formbricks/types/product";
@@ -15,6 +15,7 @@ import { WhenToSendCard } from "./WhenToSendCard";
interface SettingsViewProps {
environment: TEnvironment;
organizationId: string;
localSurvey: TSurvey;
setLocalSurvey: (survey: TSurvey) => void;
actionClasses: TActionClass[];
@@ -29,6 +30,7 @@ interface SettingsViewProps {
export const SettingsView = ({
environment,
organizationId,
localSurvey,
setLocalSurvey,
actionClasses,
@@ -49,6 +51,7 @@ export const SettingsView = ({
setLocalSurvey={setLocalSurvey}
environment={environment}
product={product}
organizationId={organizationId}
/>
{localSurvey.type === "app" ? (

View File

@@ -4,10 +4,11 @@ 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/actionClasses";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TActionClass } from "@formbricks/types/action-classes";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { TMembershipRole } from "@formbricks/types/memberships";
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
import { TProduct } from "@formbricks/types/product";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey, TSurveyEditorTabs, TSurveyStyling } from "@formbricks/types/surveys/types";
@@ -24,6 +25,7 @@ interface SurveyEditorProps {
survey: TSurvey;
product: TProduct;
environment: TEnvironment;
organizationId: string;
actionClasses: TActionClass[];
attributeClasses: TAttributeClass[];
segments: TSegment[];
@@ -34,12 +36,14 @@ interface SurveyEditorProps {
isMultiLanguageAllowed?: boolean;
isFormbricksCloud: boolean;
isUnsplashConfigured: boolean;
plan: TOrganizationBillingPlan;
}
export const SurveyEditor = ({
survey,
product,
environment,
organizationId,
actionClasses,
attributeClasses,
segments,
@@ -50,6 +54,7 @@ export const SurveyEditor = ({
isUserTargetingAllowed = false,
isFormbricksCloud,
isUnsplashConfigured,
plan,
}: SurveyEditorProps) => {
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("questions");
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
@@ -142,7 +147,7 @@ export const SurveyEditor = ({
/>
<div className="relative z-0 flex flex-1 overflow-hidden">
<main
className="relative z-0 w-1/2 flex-1 overflow-y-auto focus:outline-none"
className="relative z-0 w-1/2 flex-1 overflow-y-auto bg-slate-50 focus:outline-none"
ref={surveyEditorRef}>
<QuestionsAudienceTabs
activeId={activeView}
@@ -164,6 +169,7 @@ export const SurveyEditor = ({
isMultiLanguageAllowed={isMultiLanguageAllowed}
isFormbricksCloud={isFormbricksCloud}
attributeClasses={attributeClasses}
plan={plan}
/>
)}
@@ -185,6 +191,7 @@ export const SurveyEditor = ({
{activeView === "settings" && (
<SettingsView
environment={environment}
organizationId={organizationId}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
actionClasses={actionClasses}
@@ -199,7 +206,7 @@ export const SurveyEditor = ({
)}
</main>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 py-6 md:flex md:flex-col">
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-200 bg-slate-100 py-6 shadow-inner md:flex md:flex-col">
<PreviewSurvey
survey={localSurvey}
questionId={activeQuestionId}

View File

@@ -11,7 +11,14 @@ 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,
TSurveyQuestion,
ZSurvey,
ZSurveyEndScreenCard,
ZSurveyRedirectUrlCard,
} from "@formbricks/types/surveys/types";
import { AlertDialog } from "@formbricks/ui/AlertDialog";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
@@ -142,6 +149,7 @@ export const SurveyMenuBar = ({
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];
@@ -154,9 +162,12 @@ export const SurveyMenuBar = ({
setInvalidQuestions((prevInvalidQuestions) =>
prevInvalidQuestions ? [...prevInvalidQuestions, "start"] : ["start"]
);
} else if (currentError.path[0] === "thankYouCard") {
} else if (currentError.path[0] === "endings") {
const endingIdx = typeof currentError.path[1] === "number" ? currentError.path[1] : -1;
setInvalidQuestions((prevInvalidQuestions) =>
prevInvalidQuestions ? [...prevInvalidQuestions, "end"] : ["end"]
prevInvalidQuestions
? [...prevInvalidQuestions, localSurvey.endings[endingIdx].id]
: [localSurvey.endings[endingIdx].id]
);
}
@@ -204,6 +215,14 @@ export const SurveyMenuBar = ({
return rest;
});
localSurvey.endings = localSurvey.endings.map((ending) => {
if (ending.type === "redirectToUrl") {
return ZSurveyRedirectUrlCard.parse(ending);
} else {
return ZSurveyEndScreenCard.parse(ending);
}
});
const segment = await handleSegmentUpdate();
const updatedSurvey = await updateSurveyAction({ ...localSurvey, segment });
@@ -317,7 +336,6 @@ export const SurveyMenuBar = ({
{localSurvey.status !== "draft" && (
<Button
disabled={disableSave}
variant="darkCTA"
className="mr-3"
loading={isSurveySaving}
onClick={() => handleSaveAndGoBack()}>
@@ -326,7 +344,6 @@ export const SurveyMenuBar = ({
)}
{localSurvey.status === "draft" && audiencePrompt && !isLinkSurvey && (
<Button
variant="darkCTA"
onClick={() => {
setAudiencePrompt(false);
setActiveId("settings");
@@ -339,7 +356,6 @@ export const SurveyMenuBar = ({
{localSurvey.status === "draft" && (!audiencePrompt || isLinkSurvey) && (
<Button
disabled={isSurveySaving || containsEmptyTriggers}
variant="darkCTA"
loading={isSurveyPublishing}
onClick={handleSurveyPublish}>
Publish

View File

@@ -9,7 +9,7 @@ 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/attributeClasses";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TBaseFilter, TSegment, TSegmentCreateInput, TSegmentUpdateInput } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { AlertDialog } from "@formbricks/ui/AlertDialog";

View File

@@ -34,9 +34,10 @@ export const UpdateQuestionId = ({
}
const questionIds = localSurvey.questions.map((q) => q.id);
const endingCardIds = localSurvey.endings.map((e) => e.id);
const hiddenFieldIds = localSurvey.hiddenFields.fieldIds ?? [];
const validateIdError = validateId("Question", currentValue, questionIds, hiddenFieldIds);
const validateIdError = validateId("Question", currentValue, questionIds, endingCardIds, hiddenFieldIds);
if (validateIdError) {
setIsInputInvalid(true);
@@ -71,7 +72,7 @@ export const UpdateQuestionId = ({
disabled={localSurvey.status !== "draft" && !question.isDraft}
className={`h-10 ${isInputInvalid ? "border-red-300 focus:border-red-300" : ""}`}
/>
<Button variant="darkCTA" size="sm" onClick={saveAction} disabled={isButtonDisabled()}>
<Button size="sm" onClick={saveAction} disabled={isButtonDisabled()}>
Save
</Button>
</div>

View File

@@ -11,7 +11,7 @@ import {
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TActionClass } from "@formbricks/types/action-classes";
import { TMembershipRole } from "@formbricks/types/memberships";
import { TSurvey } from "@formbricks/types/surveys/types";
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";

View File

@@ -0,0 +1,18 @@
// formats the text to highlight specific parts of the text with slashes
export const formatTextWithSlashes = (text: string) => {
const regex = /\/(.*?)\\/g;
const parts = text.split(regex);
return parts.map((part, index) => {
// Check if the part was inside slashes
if (index % 2 !== 0) {
return (
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-xs">
{part}
</span>
);
} else {
return part;
}
});
};

View File

@@ -1,5 +1,6 @@
// extend this object in order to add more validation rules
import { toast } from "react-hot-toast";
import { z } from "zod";
import { extractLanguageCodes, getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { checkForEmptyFallBackValue } from "@formbricks/lib/utils/recall";
import { ZSegmentFilters } from "@formbricks/types/segment";
@@ -8,13 +9,14 @@ import {
TSurvey,
TSurveyCTAQuestion,
TSurveyConsentQuestion,
TSurveyEndScreenCard,
TSurveyLanguage,
TSurveyMatrixQuestion,
TSurveyMultipleChoiceQuestion,
TSurveyOpenTextQuestion,
TSurveyPictureSelectionQuestion,
TSurveyQuestion,
TSurveyThankYouCard,
TSurveyRedirectUrlCard,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
import { findLanguageCodesForDuplicateLabels } from "@formbricks/types/surveys/validation";
@@ -163,25 +165,33 @@ export const validateSurveyQuestionsInBatch = (
return invalidQuestions;
};
export const isCardValid = (
card: TSurveyWelcomeCard | TSurveyThankYouCard,
cardType: "start" | "end",
surveyLanguages: TSurveyLanguage[]
): boolean => {
const defaultLanguageCode = "default";
const isContentValid = (content: Record<string, string> | undefined) => {
return (
!content || content[defaultLanguageCode] === "" || isLabelValidForAllLanguages(content, surveyLanguages)
);
};
const isContentValid = (content: Record<string, string> | undefined, surveyLanguages: TSurveyLanguage[]) => {
return !content || isLabelValidForAllLanguages(content, surveyLanguages);
};
return (
(card.headline ? isLabelValidForAllLanguages(card.headline, surveyLanguages) : true) &&
isContentValid(
cardType === "start" ? (card as TSurveyWelcomeCard).html : (card as TSurveyThankYouCard).subheader
) &&
isContentValid(card.buttonLabel)
);
export const isWelcomeCardValid = (card: TSurveyWelcomeCard, surveyLanguages: TSurveyLanguage[]): boolean => {
return isContentValid(card.headline, surveyLanguages) && isContentValid(card.html, surveyLanguages);
};
export const isEndingCardValid = (
card: TSurveyEndScreenCard | TSurveyRedirectUrlCard,
surveyLanguages: TSurveyLanguage[]
) => {
if (card.type === "endScreen") {
return (
isContentValid(card.headline, surveyLanguages) &&
isContentValid(card.subheader, surveyLanguages) &&
isContentValid(card.buttonLabel, surveyLanguages)
);
} else {
const parseResult = z.string().url().safeParse(card.url);
if (parseResult.success) {
return card.label?.trim() !== "";
} else {
toast.error("Invalid Redirect Url in Ending card");
return false;
}
}
};
export const isSurveyValid = (survey: TSurvey, selectedLanguageCode: string) => {

View File

@@ -44,7 +44,6 @@ const Page = async ({ params }) => {
getServerSession(authOptions),
getSegments(params.environmentId),
]);
if (!session) {
throw new Error("Session not found");
}
@@ -80,10 +79,12 @@ const Page = async ({ params }) => {
attributeClasses={attributeClasses}
responseCount={responseCount}
membershipRole={currentUserMembership?.role}
organizationId={organization.id}
colors={SURVEY_BG_COLORS}
segments={segments}
isUserTargetingAllowed={isUserTargetingAllowed}
isMultiLanguageAllowed={isMultiLanguageAllowed}
plan={organization.billing.plan}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isUnsplashConfigured={UNSPLASH_ACCESS_KEY ? true : false}
/>

View File

@@ -1,3 +1,4 @@
import { getDefaultEndingCard, welcomeCardDefault } from "@formbricks/lib/templates";
import { TSurvey } from "@formbricks/types/surveys/types";
export const minimalSurvey: TSurvey = {
@@ -12,20 +13,11 @@ export const minimalSurvey: TSurvey = {
displayOption: "displayOnce",
autoClose: null,
triggers: [],
redirectUrl: null,
recontactDays: null,
displayLimit: null,
welcomeCard: {
enabled: false,
headline: { default: "Welcome!" },
html: { default: "Thanks for providing your feedback - let's go!" },
timeToFinish: false,
showResponseCount: false,
},
welcomeCard: welcomeCardDefault,
questions: [],
thankYouCard: {
enabled: false,
},
endings: [getDefaultEndingCard([])],
hiddenFields: {
enabled: false,
},
@@ -44,4 +36,5 @@ export const minimalSurvey: TSurvey = {
segment: null,
languages: [],
showLanguageSwitch: false,
isVerifyEmailEnabled: false,
};

View File

@@ -2,6 +2,7 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getUser } from "@formbricks/lib/user/service";
import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
import { TTemplateRole } from "@formbricks/types/templates";
import { TemplateContainerWithPreview } from "./components/TemplateContainer";
@@ -21,13 +22,18 @@ const Page = async ({ params, searchParams }: SurveyTemplateProps) => {
const session = await getServerSession(authOptions);
const environmentId = params.environmentId;
const [environment, product] = await Promise.all([
if (!session) {
throw new Error("Session not found");
}
const [user, environment, product] = await Promise.all([
getUser(session.user.id),
getEnvironment(environmentId),
getProductByEnvironmentId(environmentId),
]);
if (!session) {
throw new Error("Session not found");
if (!user) {
throw new Error("User not found");
}
if (!product) {
@@ -43,7 +49,7 @@ const Page = async ({ params, searchParams }: SurveyTemplateProps) => {
return (
<TemplateContainerWithPreview
environmentId={environmentId}
user={session.user}
user={user}
environment={environment}
product={product}
prefilledFilters={prefilledFilters}

View File

@@ -24,10 +24,7 @@ export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
Thanks a lot for upgrading your Formbricks subscription.
</p>
</div>
<Button
variant="darkCTA"
className="w-full justify-center"
href={`/environments/${environmentId}/settings/billing`}>
<Button className="w-full justify-center" href={`/environments/${environmentId}/settings/billing`}>
Back to billing overview
</Button>
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { formbricksEnabled } from "@/app/lib/formbricks";
import type { Session } from "next-auth";
import { usePathname, useSearchParams } from "next/navigation";
import { useCallback, useEffect } from "react";
import formbricks from "@formbricks/js/app";
@@ -10,7 +11,7 @@ type UsageAttributesUpdaterProps = {
numSurveys: number;
};
export const FormbricksClient = ({ session }) => {
export const FormbricksClient = ({ session, userEmail }: { session: Session; userEmail: string }) => {
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -20,13 +21,13 @@ export const FormbricksClient = ({ session }) => {
apiHost: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
userId: session.user.id,
});
formbricks.setEmail(session.user.email);
formbricks.setEmail(userEmail);
formbricks.registerRouteChange();
}, [session.user.email, session.user.id]);
}, [session.user.id, userEmail]);
useEffect(() => {
if (formbricksEnabled && session?.user && formbricks) {
if (formbricksEnabled && session?.user?.id && formbricks) {
initializeFormbricksAndSetupRouteChanges();
}
}, [session, pathname, searchParams, initializeFormbricksAndSetupRouteChanges]);

View File

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

View File

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

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