mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-21 13:40:31 -06:00
Compare commits
22 Commits
v2.0.2
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
474ae4478f | ||
|
|
a01107521e | ||
|
|
2e17e70c00 | ||
|
|
641b597ddc | ||
|
|
b6986e5470 | ||
|
|
82a5eed6e5 | ||
|
|
0f20a4460f | ||
|
|
95ae35a3b5 | ||
|
|
27b37a5f27 | ||
|
|
c37d3ae0f9 | ||
|
|
e69bb3501d | ||
|
|
ddd91607b1 | ||
|
|
325eeb10ef | ||
|
|
eda9c00548 | ||
|
|
6a7e0d3ecb | ||
|
|
48859facf4 | ||
|
|
732b8b599f | ||
|
|
00ad4c3895 | ||
|
|
4858bdd838 | ||
|
|
eee78a79d9 | ||
|
|
aa890affc9 | ||
|
|
10aed2d9d8 |
26
.github/workflows/build-formbricks-com.yml
vendored
26
.github/workflows/build-formbricks-com.yml
vendored
@@ -1,26 +0,0 @@
|
||||
name: Build formbricks-com
|
||||
on:
|
||||
workflow_call:
|
||||
jobs:
|
||||
build:
|
||||
name: Build Formbricks-com
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: Build Formbricks-com
|
||||
run: pnpm build --filter=formbricks-com...
|
||||
131
.github/workflows/kamal-deploy.yml
vendored
131
.github/workflows/kamal-deploy.yml
vendored
@@ -1,131 +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 }}
|
||||
NEXTAUTH_URL: ${{ vars.NEXTAUTH_URL }}
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
|
||||
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
|
||||
SHORT_URL_BASE: ${{ vars.SHORT_URL_BASE }}
|
||||
MAIL_FROM: ${{ secrets.MAIL_FROM }}
|
||||
SMTP_HOST: ${{ secrets.SMTP_HOST }}
|
||||
SMTP_PORT: ${{ secrets.SMTP_PORT }}
|
||||
SMTP_USER: ${{ secrets.SMTP_USER }}
|
||||
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
|
||||
PRIVACY_URL: ${{ vars.PRIVACY_URL }}
|
||||
TERMS_URL: ${{ vars.TERMS_URL }}
|
||||
IMPRINT_URL: ${{ vars.IMPRINT_URL }}
|
||||
GITHUB_ID: ${{ secrets.FB_GITHUB_ID }}
|
||||
GITHUB_SECRET: ${{ secrets.FB_GITHUB_SECRET }}
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
AZUREAD_CLIENT_ID: ${{ secrets.AZUREAD_CLIENT_ID }}
|
||||
AZUREAD_CLIENT_SECRET: ${{ secrets.AZUREAD_CLIENT_SECRET }}
|
||||
AZUREAD_TENANT_ID: ${{ secrets.AZUREAD_TENANT_ID }}
|
||||
OIDC_CLIENT_ID: ${{ secrets.OIDC_CLIENT_ID }}
|
||||
OIDC_CLIENT_SECRET: ${{ secrets.OIDC_CLIENT_SECRET }}
|
||||
OIDC_ISSUER: ${{ secrets.OIDC_ISSUER }}
|
||||
OIDC_DISPLAY_NAME: ${{ secrets.OIDC_DISPLAY_NAME }}
|
||||
OIDC_SIGNING_ALGORITHM: ${{ secrets.OIDC_SIGNING_ALGORITHM }}
|
||||
CRON_SECRET: ${{ secrets.CRON_SECRET }}
|
||||
ASSET_PREFIX_URL: ${{ vars.ASSET_PREFIX_URL }}
|
||||
NOTION_OAUTH_CLIENT_ID: ${{ secrets.NOTION_OAUTH_CLIENT_ID }}
|
||||
NOTION_OAUTH_CLIENT_SECRET: ${{ secrets.NOTION_OAUTH_CLIENT_SECRET }}
|
||||
SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }}
|
||||
SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }}
|
||||
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
||||
GOOGLE_SHEETS_CLIENT_ID: ${{ secrets.GOOGLE_SHEETS_CLIENT_ID }}
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: ${{ secrets.GOOGLE_SHEETS_CLIENT_SECRET }}
|
||||
GOOGLE_SHEETS_REDIRECT_URL: ${{ secrets.GOOGLE_SHEETS_REDIRECT_URL }}
|
||||
AIRTABLE_CLIENT_ID: ${{ secrets.AIRTABLE_CLIENT_ID }}
|
||||
ENTERPRISE_LICENSE_KEY: ${{ secrets.ENTERPRISE_LICENSE_KEY }}
|
||||
DEFAULT_TEAM_ID: ${{ vars.DEFAULT_TEAM_ID }}
|
||||
ONBOARDING_DISABLED: ${{ vars.ONBOARDING_DISABLED }}
|
||||
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
|
||||
128
.github/workflows/kamal-setup.yml
vendored
128
.github/workflows/kamal-setup.yml
vendored
@@ -1,128 +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 }}
|
||||
NEXTAUTH_URL: ${{ vars.NEXTAUTH_URL }}
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
MIGRATE_DATABASE_URL: ${{ secrets.MIGRATE_DATABASE_URL }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
|
||||
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
|
||||
SHORT_URL_BASE: ${{ vars.SHORT_URL_BASE }}
|
||||
MAIL_FROM: ${{ secrets.MAIL_FROM }}
|
||||
SMTP_HOST: ${{ secrets.SMTP_HOST }}
|
||||
SMTP_PORT: ${{ secrets.SMTP_PORT }}
|
||||
SMTP_USER: ${{ secrets.SMTP_USER }}
|
||||
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
|
||||
PRIVACY_URL: ${{ vars.PRIVACY_URL }}
|
||||
TERMS_URL: ${{ vars.TERMS_URL }}
|
||||
IMPRINT_URL: ${{ vars.IMPRINT_URL }}
|
||||
GITHUB_ID: ${{ secrets.FB_GITHUB_ID }}
|
||||
GITHUB_SECRET: ${{ secrets.FB_GITHUB_SECRET }}
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
AZUREAD_CLIENT_ID: ${{ secrets.AZUREAD_CLIENT_ID }}
|
||||
AZUREAD_CLIENT_SECRET: ${{ secrets.AZUREAD_CLIENT_SECRET }}
|
||||
AZUREAD_TENANT_ID: ${{ secrets.AZUREAD_TENANT_ID }}
|
||||
OIDC_CLIENT_ID: ${{ secrets.OIDC_CLIENT_ID }}
|
||||
OIDC_CLIENT_SECRET: ${{ secrets.OIDC_CLIENT_SECRET }}
|
||||
OIDC_ISSUER: ${{ secrets.OIDC_ISSUER }}
|
||||
OIDC_DISPLAY_NAME: ${{ secrets.OIDC_DISPLAY_NAME }}
|
||||
OIDC_SIGNING_ALGORITHM: ${{ secrets.OIDC_SIGNING_ALGORITHM }}
|
||||
CRON_SECRET: ${{ secrets.CRON_SECRET }}
|
||||
ASSET_PREFIX_URL: ${{ vars.ASSET_PREFIX_URL }}
|
||||
NOTION_OAUTH_CLIENT_ID: ${{ secrets.NOTION_OAUTH_CLIENT_ID }}
|
||||
NOTION_OAUTH_CLIENT_SECRET: ${{ secrets.NOTION_OAUTH_CLIENT_SECRET }}
|
||||
SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }}
|
||||
SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }}
|
||||
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
||||
GOOGLE_SHEETS_CLIENT_ID: ${{ secrets.GOOGLE_SHEETS_CLIENT_ID }}
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: ${{ secrets.GOOGLE_SHEETS_CLIENT_SECRET }}
|
||||
GOOGLE_SHEETS_REDIRECT_URL: ${{ secrets.GOOGLE_SHEETS_REDIRECT_URL }}
|
||||
AIRTABLE_CLIENT_ID: ${{ secrets.AIRTABLE_CLIENT_ID }}
|
||||
ENTERPRISE_LICENSE_KEY: ${{ secrets.ENTERPRISE_LICENSE_KEY }}
|
||||
DEFAULT_TEAM_ID: ${{ vars.DEFAULT_TEAM_ID }}
|
||||
ONBOARDING_DISABLED: ${{ vars.ONBOARDING_DISABLED }}
|
||||
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
|
||||
19
.github/workflows/release-docker-github.yml
vendored
19
.github/workflows/release-docker-github.yml
vendored
@@ -1,9 +1,4 @@
|
||||
name: Docker
|
||||
|
||||
# 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.
|
||||
name: Docker Release to GitHub
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -53,6 +48,17 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set tags based on event type
|
||||
id: set-tags
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "push" ]]; then
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
echo "::set-output name=tags::latest,${{ github.ref }}"
|
||||
fi
|
||||
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
echo "::set-output name=tags::experimental"
|
||||
fi
|
||||
|
||||
# Extract metadata (tags, labels) for Docker
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
@@ -60,6 +66,7 @@ jobs:
|
||||
uses: docker/metadata-action@v5 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: ${{ steps.set-tags.outputs.tags }}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
|
||||
44
.github/workflows/release-docker.yml
vendored
44
.github/workflows/release-docker.yml
vendored
@@ -1,44 +0,0 @@
|
||||
name: Release on Dockerhub
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
release-image-on-dockerhub:
|
||||
name: Release on Dockerhub
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Get Release Tag
|
||||
id: extract_release_tag
|
||||
run: |
|
||||
TAG=${{ github.ref }}
|
||||
TAG=${TAG#refs/tags/v}
|
||||
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./apps/web/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKER_USERNAME }}/formbricks:${{ env.RELEASE_TAG }}
|
||||
${{ secrets.DOCKER_USERNAME }}/formbricks:latest
|
||||
@@ -24,8 +24,7 @@ export const metadata = {
|
||||
The Airtable integration allows you to automatically send responses to an Airtable of your choice.
|
||||
|
||||
<Note>
|
||||
This feature is enabled by default in Formbricks Cloud but needs to be self-configured when running a
|
||||
self-hosted version of Formbricks.
|
||||
If you are on a self-hosted instance, you will need to configure this integration separately. Please follow the guides [here](/self-hosting/integrations) to configure integrations on your self-hosted instance.
|
||||
</Note>
|
||||
|
||||
## Formbricks Cloud
|
||||
|
||||
@@ -21,9 +21,7 @@ export const metadata = {
|
||||
The Google Sheets integration allows you to automatically send responses to a Google Sheet of your choice.
|
||||
|
||||
<Note>
|
||||
This feature is enabled by default in Formbricks Cloud but needs to be self-configured when running a
|
||||
self-hosted version of Formbricks. For self-configuration, see additional setup
|
||||
[below](#setup-in-self-hosted-formbricks).
|
||||
If you are on a self-hosted instance, you will need to configure this integration separately. Please follow the guides [here](/self-hosting/integrations) to configure integrations on your self-hosted instance.
|
||||
</Note>
|
||||
|
||||
## Connect Google Sheets
|
||||
|
||||
@@ -27,7 +27,7 @@ export const metadata = {
|
||||
Make is a powerful tool to send information between Formbricks and thousands of apps. Here's how to set it up.
|
||||
|
||||
<Note>
|
||||
### Nail down your survey first ? Any changes in the survey cause additional work in the _Scenario_. It
|
||||
Nailed down your survey?? Any changes in the survey cause additional work in the _Scenario_. It
|
||||
makes sense to first settle on the survey you want to run and then get to setting up Make.
|
||||
</Note>
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export const metadata = {
|
||||
n8n allows you to build flexible workflows focused on deep data integration. And with sharable templates and a user-friendly UI, the less technical people on your team can collaborate on them too. Unlike other tools, complexity is not a limitation. So you can build whatever you want — without stressing over budget. Hook up Formbricks with n8n and you can send your data to 350+ other apps. Here is how to do it.
|
||||
|
||||
<Note>
|
||||
### Nail down your survey first Any changes in the survey cause additional work in the n8n node. It makes
|
||||
Nail down your survey? Any changes in the survey cause additional work in the n8n node. It makes
|
||||
sense to first settle on the survey you want to run and then get to setting up n8n.
|
||||
</Note>
|
||||
|
||||
|
||||
@@ -21,8 +21,7 @@ export const metadata = {
|
||||
The notion integration allows you to automatically send responses to a Notion database of your choice.
|
||||
|
||||
<Note>
|
||||
This feature is enabled by default in Formbricks Cloud but needs to be self-configured when running a
|
||||
self-hosted version of Formbricks.
|
||||
If you are on a self-hosted instance, you will need to configure this integration separately. Please follow the guides [here](/self-hosting/integrations) to configure integrations on your self-hosted instance.
|
||||
</Note>
|
||||
|
||||
## Formbricks Cloud
|
||||
|
||||
@@ -10,7 +10,7 @@ export const metadata = {
|
||||
At Formbricks, we understand the importance of integrating with third-party applications. We have step-by-step guides to configure our third-party integrations with a your Formbricks instance. We currently support the below integrations, click on them to see their individual guides:
|
||||
|
||||
<Note>
|
||||
If you are on a self-hosted instance, you will need to configure these integrations manually. Please follow the guides [here](/self-hosting/integrations) to configure them.
|
||||
If you are on a self-hosted instance, you will need to configure these integrations manually. Please follow the guides [here](/self-hosting/integrations) to configure integrations on your self-hosted instance.
|
||||
</Note>
|
||||
|
||||
- [Airtable](/developer-docs/integrations/airtable): Automatically send responses to an Airtable of your choice.
|
||||
|
||||
@@ -22,8 +22,7 @@ export const metadata = {
|
||||
The slack integration allows you to automatically send responses to a Slack channel of your choice.
|
||||
|
||||
<Note>
|
||||
This feature is enabled by default in Formbricks Cloud but needs to be self-configured when running a
|
||||
self-hosted version of Formbricks.
|
||||
If you are on a self-hosted instance, you will need to configure this integration separately. Please follow the guides [here](/self-hosting/integrations) to configure integrations on your self-hosted instance.
|
||||
</Note>
|
||||
|
||||
## Formbricks Cloud
|
||||
|
||||
@@ -8,7 +8,7 @@ export const metadata = {
|
||||
|
||||
# Advanced Setup
|
||||
|
||||
Quickly set up and start using Formbricks with our [official Docker image](https://github.com/formbricks/formbricks/pkgs/container/formbricks) that we've already built for you.
|
||||
Quickly set up and start using Formbricks with our [official Docker image](https://github.com/formbricks/formbricks/pkgs/container/formbricks) that we've already built for you.
|
||||
|
||||
The pre-built image is ready-to-run, and it only requires minimal configuration on your part. It's as easy as downloading the Docker image and firing up the container.
|
||||
|
||||
@@ -104,20 +104,12 @@ You're now ready to start the Formbricks Docker setup. The following command wil
|
||||
|
||||
## Update
|
||||
|
||||
1. Stop the Formbricks stack
|
||||
<Note>
|
||||
Please take a look at our [migration guide](/self-hosting/migration-guide) for version specific steps to
|
||||
update Formbricks.
|
||||
</Note>
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Stop the docker instance">
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
</Col>
|
||||
|
||||
2. Pull the latest changes
|
||||
1. Pull the latest Formbricks image
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Pull the changes into docker">
|
||||
@@ -130,8 +122,20 @@ You're now ready to start the Formbricks Docker setup. The following command wil
|
||||
|
||||
</Col>
|
||||
|
||||
3. Update env vars as necessary in the docker-compose file.
|
||||
4. Re-start the Formbricks stack
|
||||
2. Stop the Formbricks stack
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Stop the docker instance">
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
</Col>
|
||||
|
||||
3. Re-start the Formbricks stack with the updated image
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Relaunch the Docker Instance">
|
||||
|
||||
@@ -39,10 +39,10 @@ We have step-by-step guides to configure our third-party integrations with a sel
|
||||
- [Airtable](#airtable)
|
||||
- [Google Sheets](#google-sheets)
|
||||
- [Notion](#notion)
|
||||
- Make: We do not support [Make.com](http://Make.com) for Self-hosted instances yet! Please follow our Cloud guide [here](/integrations#make)
|
||||
- Make: We do not support for self-hosted instances yet.
|
||||
- [n8n](#n8n)
|
||||
- [Slack](#slack)
|
||||
- [Wordpress]: Wordpress setup is similar to steps mentioned in Cloud [here](/integrations#wordpress), just change the API Host to your self-hosted URL.
|
||||
- Wordpress: Wordpress setup is similar to the [Cloud setup](/developer-docs/integrations/wordpress), just change the API Host to your self-hosted URL.
|
||||
- [Zapier](#zapier)
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -20,7 +20,9 @@ Formbricks v2.0 comes with huge features such as Multi-Language Surveys and Adva
|
||||
and
|
||||
|
||||
<Note>
|
||||
If you've used the Formbricks Enterprise Edition with a free beta license key, your instance will be downgraded to the Community Edition 2.0. You find all license details on the [license page.](/self-hosting/license/)
|
||||
If you've used the Formbricks Enterprise Edition with a free beta license key, your instance will be
|
||||
downgraded to the Community Edition 2.0. You find all license details on the [license
|
||||
page.](/self-hosting/license/)
|
||||
</Note>
|
||||
|
||||
### Steps to Migrate
|
||||
@@ -35,7 +37,7 @@ To run all these steps, please navigate to the `formbricks` folder where your `d
|
||||
<CodeGroup title="Backup Postgres">
|
||||
|
||||
```bash
|
||||
docker exec formbricks-quickstart-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbricks_pre_v2.0_$(date +%Y%m%d_%H%M%S).dump
|
||||
docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbricks_pre_v2.0_$(date +%Y%m%d_%H%M%S).dump
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
@@ -51,7 +53,19 @@ docker exec formbricks-quickstart-postgres-1 pg_dump -Fc -U postgres -d formbric
|
||||
restore scenario you will need to use `psql` then with an empty `formbricks` database.
|
||||
</Note>
|
||||
|
||||
2. Stop the running Formbricks instance & remove the related containers:
|
||||
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">
|
||||
@@ -63,7 +77,7 @@ docker-compose down
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
3. Restarting the containers will automatically pull the latest version of Formbricks:
|
||||
4. Restarting the containers with the latest version of Formbricks:
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Restart the containers">
|
||||
@@ -75,7 +89,7 @@ docker-compose up -d
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
4. Now let's migrate the data to the latest schema:
|
||||
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>
|
||||
|
||||
@@ -83,6 +97,7 @@ docker-compose up -d
|
||||
<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" \
|
||||
@@ -95,7 +110,7 @@ docker run --rm \
|
||||
|
||||
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.
|
||||
|
||||
5. That's it! Once the migration is complete, you can **now access your Formbricks instance** at the same URL as before.
|
||||
6. That's it! Once the migration is complete, you can **now access your Formbricks instance** at the same URL as before.
|
||||
|
||||
### App Surveys with @formbricks/js
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export const Layout = ({
|
||||
</Link>
|
||||
</div>
|
||||
<Header />
|
||||
<Navigation className="hidden lg:mt-10 lg:block" />
|
||||
<Navigation className="hidden lg:mt-10 lg:block" isMobile={false} />
|
||||
</div>
|
||||
</motion.header>
|
||||
<div className="relative flex h-full flex-col px-4 pt-14 sm:px-6 lg:px-8">
|
||||
|
||||
@@ -90,7 +90,7 @@ const MobileNavigationDialog = ({ isOpen, close }: { isOpen: boolean; close: ()
|
||||
<motion.div
|
||||
layoutScroll
|
||||
className="ring-zinc-900/7.5 fixed bottom-0 left-0 top-14 w-full overflow-y-auto bg-white px-4 pb-4 pt-6 shadow-lg shadow-zinc-900/10 ring-1 min-[416px]:max-w-sm sm:px-6 sm:pb-10 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||
<Navigation />
|
||||
<Navigation isMobile={true} />
|
||||
</motion.div>
|
||||
</Transition.Child>
|
||||
</Dialog.Panel>
|
||||
|
||||
@@ -46,25 +46,41 @@ const NavLink = ({
|
||||
active = false,
|
||||
isAnchorLink = false,
|
||||
}: {
|
||||
href: string;
|
||||
href?: string;
|
||||
children: React.ReactNode;
|
||||
active: boolean;
|
||||
isAnchorLink?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={clsx(
|
||||
"flex justify-between gap-2 py-1 pr-3 text-sm transition",
|
||||
isAnchorLink ? "pl-7" : "pl-4",
|
||||
active
|
||||
? "rounded-r-md bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-white"
|
||||
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
)}>
|
||||
<span className="flex w-full truncate">{children}</span>
|
||||
</Link>
|
||||
const commonClasses = clsx(
|
||||
"flex justify-between gap-2 py-1 pr-3 text-sm transition",
|
||||
isAnchorLink ? "pl-7" : "pl-4",
|
||||
active
|
||||
? "rounded-r-md bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-white"
|
||||
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={clsx(
|
||||
"flex justify-between gap-2 py-1 pr-3 text-sm transition",
|
||||
isAnchorLink ? "pl-7" : "pl-4",
|
||||
active
|
||||
? "rounded-r-md bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-white"
|
||||
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
)}>
|
||||
<span className="flex w-full truncate">{children}</span>
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div aria-current={active ? "page" : undefined} className={commonClasses}>
|
||||
<span className="flex w-full truncate">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const VisibleSectionHighlight = ({ group, pathname }: { group: NavGroup; pathname: string }) => {
|
||||
@@ -131,6 +147,7 @@ const NavigationGroup = ({
|
||||
setActiveGroup,
|
||||
openGroups,
|
||||
setOpenGroups,
|
||||
isMobile,
|
||||
}: {
|
||||
group: NavGroup;
|
||||
className?: string;
|
||||
@@ -138,6 +155,7 @@ const NavigationGroup = ({
|
||||
setActiveGroup: (group: NavGroup | null) => void;
|
||||
openGroups: string[];
|
||||
setOpenGroups: (groups: string[]) => void;
|
||||
isMobile: boolean;
|
||||
}) => {
|
||||
const isInsideMobileNavigation = useIsInsideMobileNavigation();
|
||||
const pathname = usePathname();
|
||||
@@ -171,13 +189,15 @@ const NavigationGroup = ({
|
||||
{group.links.map((link) => (
|
||||
<motion.li key={link.title} layout="position" className="relative">
|
||||
{link.href ? (
|
||||
<NavLink href={link.href} active={!!pathname?.startsWith(link.href)}>
|
||||
<NavLink
|
||||
href={isMobile && link.children ? "" : link.href}
|
||||
active={!!pathname?.startsWith(link.href)}>
|
||||
{link.title}
|
||||
</NavLink>
|
||||
) : (
|
||||
<div onClick={() => toggleParentTitle(link.title)}>
|
||||
<NavLink
|
||||
href={link.children?.[0]?.href || ""}
|
||||
href={!isMobile ? link.children?.[0]?.href || "" : undefined}
|
||||
active={
|
||||
!!(
|
||||
isParentOpen(link.title) &&
|
||||
@@ -197,7 +217,7 @@ const NavigationGroup = ({
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{link.children && isParentOpen(link.title) && (
|
||||
{isActiveGroup && link.children && isParentOpen(link.title) && (
|
||||
<motion.ul
|
||||
role="list"
|
||||
initial={{ opacity: 0 }}
|
||||
@@ -221,7 +241,11 @@ const NavigationGroup = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const Navigation = (props: React.ComponentPropsWithoutRef<"nav">) => {
|
||||
interface NavigationProps extends React.ComponentPropsWithoutRef<"nav"> {
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
export const Navigation = ({ isMobile, ...props }: NavigationProps) => {
|
||||
const [activeGroup, setActiveGroup] = useState<NavGroup | null>(navigation[0]);
|
||||
const [openGroups, setOpenGroups] = useState<string[]>([]);
|
||||
|
||||
@@ -237,6 +261,7 @@ export const Navigation = (props: React.ComponentPropsWithoutRef<"nav">) => {
|
||||
setActiveGroup={setActiveGroup}
|
||||
openGroups={openGroups}
|
||||
setOpenGroups={setOpenGroups}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
))}
|
||||
<li className="sticky bottom-0 z-10 mt-6 min-[416px]:hidden">
|
||||
|
||||
@@ -18,12 +18,11 @@ RUN turbo prune @formbricks/web --docker
|
||||
FROM base AS installer
|
||||
|
||||
# Enable corepack and prepare pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable
|
||||
|
||||
# Install necessary build tools and compilers
|
||||
RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq
|
||||
|
||||
|
||||
# Set hardcoded environment variables
|
||||
ENV DATABASE_URL="postgresql://placeholder:for@build:5432/gets_overwritten_at_runtime?schema=public"
|
||||
ENV NEXTAUTH_SECRET="placeholder_for_next_auth_of_64_chars_get_overwritten_at_runtime"
|
||||
@@ -59,7 +58,7 @@ RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_ver
|
||||
## step 3: setup production runner
|
||||
#
|
||||
FROM base AS runner
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable
|
||||
|
||||
RUN apk add --no-cache curl \
|
||||
&& apk add --no-cache supercronic \
|
||||
@@ -92,5 +91,5 @@ RUN mkdir -p /home/nextjs/apps/web/uploads/
|
||||
VOLUME /home/nextjs/apps/web/uploads/
|
||||
|
||||
CMD supercronic -quiet /app/docker/cronjobs & \
|
||||
(cd packages/database && pnpm db:migrate:deploy) && \
|
||||
(cd packages/database && npm run db:migrate:deploy) && \
|
||||
exec node apps/web/server.js
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { formbricksEnabled } from "@/app/lib/formbricks";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
import formbricks from "@formbricks/js/app";
|
||||
import { env } from "@formbricks/lib/env";
|
||||
@@ -15,22 +15,23 @@ export const FormbricksClient = ({ session }) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (formbricksEnabled && session?.user && formbricks) {
|
||||
formbricks.init({
|
||||
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
|
||||
apiHost: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
|
||||
userId: session.user.id,
|
||||
});
|
||||
formbricks.setEmail(session.user.email);
|
||||
}
|
||||
}, [session]);
|
||||
const initializeFormbricksAndSetupRouteChanges = useCallback(async () => {
|
||||
formbricks.init({
|
||||
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
|
||||
apiHost: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
|
||||
userId: session.user.id,
|
||||
});
|
||||
formbricks.setEmail(session.user.email);
|
||||
|
||||
formbricks.registerRouteChange();
|
||||
}, [session.user.email, session.user.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (formbricksEnabled && formbricks) {
|
||||
formbricks?.registerRouteChange();
|
||||
if (formbricksEnabled && session?.user && formbricks) {
|
||||
initializeFormbricksAndSetupRouteChanges();
|
||||
}
|
||||
}, [pathname, searchParams]);
|
||||
}, [session, pathname, searchParams, initializeFormbricksAndSetupRouteChanges]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -200,7 +200,7 @@ export const ActionSettingsTab = ({
|
||||
This is a code action. Please make changes in your code base.
|
||||
</p>
|
||||
) : actionClass.type === "noCode" ? (
|
||||
<>
|
||||
<div className="max-h-60 overflow-auto">
|
||||
<div>
|
||||
<Label>Select By</Label>
|
||||
</div>
|
||||
@@ -225,7 +225,7 @@ export const ActionSettingsTab = ({
|
||||
setIsInnerHtml={setIsInnerHtml}
|
||||
register={register}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
) : actionClass.type === "automatic" ? (
|
||||
<p className="text-sm text-slate-600">
|
||||
This action was created automatically. You cannot make changes to it.
|
||||
|
||||
@@ -205,7 +205,7 @@ export const MainNavigation = ({
|
||||
{product && (
|
||||
<aside
|
||||
className={cn(
|
||||
"z-20 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
|
||||
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
|
||||
!isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded",
|
||||
environment.type === "development" ? `h-[calc(100vh-1.25rem)]` : "h-screen"
|
||||
)}>
|
||||
|
||||
@@ -11,7 +11,7 @@ interface SideBarProps {
|
||||
|
||||
export const TopControlBar = ({ environment, environments }: SideBarProps) => {
|
||||
return (
|
||||
<div className="fixed inset-0 top-0 z-10 flex h-14 w-full items-center justify-end bg-slate-50 px-6">
|
||||
<div className="fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6">
|
||||
<div className="shadow-xs z-10">
|
||||
<div className="flex w-fit space-x-2 py-2">
|
||||
<WidgetStatusIndicator environment={environment} type="mini" />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
|
||||
import FormbricksLogo from "@/images/logo.svg";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
@@ -41,6 +42,12 @@ export const AirtableConnect = ({ environmentId, enabled, webAppUrl }: AirtableC
|
||||
{!enabled && (
|
||||
<p className="mb-8 rounded border-slate-200 bg-slate-100 p-3 text-sm">
|
||||
Airtable Integration is not configured in your instance of Formbricks.
|
||||
<br />
|
||||
Please follow the{" "}
|
||||
<Link href="https://formbricks.com/docs/self-hosting/integrations#airtable" className="underline">
|
||||
docs
|
||||
</Link>{" "}
|
||||
to configure it.
|
||||
</p>
|
||||
)}
|
||||
<Button variant="darkCTA" loading={isConnecting} onClick={handleGoogleLogin} disabled={!enabled}>
|
||||
|
||||
@@ -44,7 +44,9 @@ export const Connect = ({ enabled, environmentId, webAppUrl }: ConnectProps) =>
|
||||
Google Sheets Integration is not configured in your instance of Formbricks.
|
||||
<br />
|
||||
Please follow the{" "}
|
||||
<Link href="https://formbricks.com/docs/integrations/google-sheets" className="underline">
|
||||
<Link
|
||||
href="https://formbricks.com/docs/self-hosting/integrations#google-sheets"
|
||||
className="underline">
|
||||
docs
|
||||
</Link>{" "}
|
||||
to configure it.
|
||||
|
||||
@@ -54,7 +54,7 @@ export const Connect = ({ enabled, environmentId, webAppUrl }: ConnectProps) =>
|
||||
Notion Integration is not configured in your instance of Formbricks.
|
||||
<br />
|
||||
Please follow the{" "}
|
||||
<Link href="https://formbricks.com/docs/integrations/notion" className="underline">
|
||||
<Link href="https://formbricks.com/docs/self-hosting/integrations#notion" className="underline">
|
||||
docs
|
||||
</Link>{" "}
|
||||
to configure it.
|
||||
|
||||
@@ -56,7 +56,7 @@ export const Connect = ({ isEnabled, environmentId, webAppUrl }: ConnectProps) =
|
||||
Slack Integration is not configured in your instance of Formbricks.
|
||||
<br />
|
||||
Please follow the{" "}
|
||||
<Link href="https://formbricks.com/docs/integrations/slack" className="underline">
|
||||
<Link href="https://formbricks.com/docs/self-hosting/integrations#slack" className="underline">
|
||||
docs
|
||||
</Link>{" "}
|
||||
to configure it.
|
||||
|
||||
@@ -9,9 +9,30 @@ import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service"
|
||||
import { deleteProduct, getProducts, updateProduct } from "@formbricks/lib/product/service";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { Result, ok } from "@formbricks/types/errorHandlers";
|
||||
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TProduct, TProductUpdateInput } from "@formbricks/types/product";
|
||||
|
||||
interface State {
|
||||
params: { environmentId: string; productId: string };
|
||||
response?: Result<TProduct>;
|
||||
}
|
||||
|
||||
export const updateProductFormAction = async (state: State, data: FormData): Promise<State> => {
|
||||
console.log({ state });
|
||||
const formData = Object.fromEntries(data);
|
||||
console.log({ formData });
|
||||
|
||||
const updatedProduct = await updateProductAction(state.params.environmentId, state.params.productId, {
|
||||
name: formData.name as string,
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
response: ok(updatedProduct),
|
||||
};
|
||||
};
|
||||
|
||||
export const updateProductAction = async (
|
||||
environmentId: string,
|
||||
productId: string,
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
import { updateProductAction } from "../actions";
|
||||
|
||||
type TEditProductName = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
type EditProductNameProps = {
|
||||
product: TProduct;
|
||||
environmentId: string;
|
||||
isProductNameEditDisabled: boolean;
|
||||
};
|
||||
|
||||
export const EditProductName: React.FC<EditProductNameProps> = ({
|
||||
product,
|
||||
environmentId,
|
||||
isProductNameEditDisabled,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
watch,
|
||||
} = useForm<TEditProductName>({
|
||||
defaultValues: {
|
||||
name: product.name,
|
||||
},
|
||||
});
|
||||
const productNameValue = watch("name", product.name || "");
|
||||
const isNotEmptySpaces = (value: string) => value.trim() !== "";
|
||||
|
||||
const updateProduct: SubmitHandler<TEditProductName> = async (data) => {
|
||||
data.name = data.name.trim();
|
||||
try {
|
||||
if (!isNotEmptySpaces(data.name)) {
|
||||
toast.error("Please enter at least one character");
|
||||
return;
|
||||
}
|
||||
if (data.name === product.name) {
|
||||
toast.success("This is already your product name");
|
||||
return;
|
||||
}
|
||||
const updatedProduct = await updateProductAction(environmentId, product.id, { name: data.name });
|
||||
if (isProductNameEditDisabled) {
|
||||
toast.error("Only Owners, Admins and Editors can perform this action.");
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
if (!!updatedProduct?.id) {
|
||||
toast.success("Product name updated successfully.");
|
||||
router.refresh();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error(`Error: Unable to save product information`);
|
||||
}
|
||||
};
|
||||
|
||||
return !isProductNameEditDisabled ? (
|
||||
<form className="w-full max-w-sm items-center space-y-2" onSubmit={handleSubmit(updateProduct)}>
|
||||
<Label htmlFor="fullname">What's your product called?</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="fullname"
|
||||
defaultValue={product.name}
|
||||
{...register("name", { required: true })}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
disabled={!isNotEmptySpaces(productNameValue) || isSubmitting}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="text-sm text-red-700">Only Owners, Admins and Editors can perform this action.</p>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRef } from "react";
|
||||
import { useFormState } from "react-dom";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TProduct, ZProduct } from "@formbricks/types/product";
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage, FormProvider } from "@formbricks/ui/Form";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
|
||||
import { updateProductAction, updateProductFormAction } from "../actions";
|
||||
import { SubmitButton } from "./SubmitBtn";
|
||||
|
||||
type EditProductNameProps = {
|
||||
product: TProduct;
|
||||
environmentId: string;
|
||||
isProductNameEditDisabled: boolean;
|
||||
};
|
||||
|
||||
const ZProductNameInput = ZProduct.pick({ name: true });
|
||||
|
||||
type TEditProductName = z.infer<typeof ZProductNameInput>;
|
||||
|
||||
export const EditProductNameForm: React.FC<EditProductNameProps> = ({
|
||||
product,
|
||||
environmentId,
|
||||
isProductNameEditDisabled,
|
||||
}) => {
|
||||
const form = useForm<TEditProductName>({
|
||||
defaultValues: {
|
||||
name: product.name,
|
||||
},
|
||||
resolver: zodResolver(ZProductNameInput),
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const [serverState, formAction] = useFormState(updateProductFormAction, {
|
||||
params: { environmentId, productId: product.id },
|
||||
});
|
||||
|
||||
const { errors, isDirty } = form.formState;
|
||||
|
||||
const nameError = errors.name?.message;
|
||||
// const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
// const updateProduct: SubmitHandler<TEditProductName> = async (data) => {
|
||||
// const name = data.name.trim();
|
||||
// try {
|
||||
// if (nameError) {
|
||||
// toast.error(nameError);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const updatedProduct = await updateProductAction(environmentId, product.id, { name });
|
||||
|
||||
// if (isProductNameEditDisabled) {
|
||||
// toast.error("Only Owners, Admins and Editors can perform this action.");
|
||||
// return;
|
||||
// }
|
||||
|
||||
// if (!!updatedProduct?.id) {
|
||||
// toast.success("Product name updated successfully.");
|
||||
// form.resetField("name", { defaultValue: updatedProduct.name });
|
||||
// }
|
||||
// } catch (err) {
|
||||
// console.error(err);
|
||||
// toast.error(`Error: Unable to save product information`);
|
||||
// }
|
||||
// };
|
||||
|
||||
return !isProductNameEditDisabled ? (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
ref={formRef}
|
||||
className="w-full max-w-sm items-center space-y-2"
|
||||
action={formAction}
|
||||
onSubmit={(e) =>
|
||||
form.handleSubmit(() => {
|
||||
e.preventDefault();
|
||||
formRef.current?.submit();
|
||||
})
|
||||
}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="name">What's your product called?</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
{...field}
|
||||
placeholder="Product Name"
|
||||
autoComplete="off"
|
||||
required
|
||||
isInvalid={!!nameError}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SubmitButton />
|
||||
</form>
|
||||
</FormProvider>
|
||||
) : (
|
||||
<p className="text-sm text-red-700">Only Owners, Admins and Editors can perform this action.</p>
|
||||
);
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
import { updateProductAction } from "../actions";
|
||||
|
||||
type EditWaitingTimeFormValues = {
|
||||
recontactDays: number;
|
||||
};
|
||||
|
||||
type EditWaitingTimeProps = {
|
||||
environmentId: string;
|
||||
product: TProduct;
|
||||
};
|
||||
|
||||
export const EditWaitingTime: React.FC<EditWaitingTimeProps> = ({ product, environmentId }) => {
|
||||
const router = useRouter();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<EditWaitingTimeFormValues>({
|
||||
defaultValues: {
|
||||
recontactDays: product.recontactDays,
|
||||
},
|
||||
});
|
||||
|
||||
const updateWaitingTime: SubmitHandler<EditWaitingTimeFormValues> = async (data) => {
|
||||
try {
|
||||
const updatedProduct = await updateProductAction(environmentId, product.id, data);
|
||||
if (!!updatedProduct?.id) {
|
||||
toast.success("Waiting period updated successfully.");
|
||||
router.refresh();
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="w-full max-w-sm items-center space-y-2" onSubmit={handleSubmit(updateWaitingTime)}>
|
||||
<Label htmlFor="recontactDays">Wait X days before showing next survey:</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="recontactDays"
|
||||
defaultValue={product.recontactDays}
|
||||
{...register("recontactDays", {
|
||||
min: { value: 0, message: "Must be a positive number" },
|
||||
max: { value: 365, message: "Must be less than 365" },
|
||||
valueAsNumber: true,
|
||||
required: {
|
||||
value: true,
|
||||
message: "Required",
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
{errors?.recontactDays ? (
|
||||
<div className="my-2">
|
||||
<p className="text-xs text-red-500">{errors?.recontactDays?.message}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" variant="darkCTA" size="sm">
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TProduct, ZProduct } from "@formbricks/types/product";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage, FormProvider } from "@formbricks/ui/Form";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
|
||||
import { updateProductAction } from "../actions";
|
||||
|
||||
type EditWaitingTimeProps = {
|
||||
environmentId: string;
|
||||
product: TProduct;
|
||||
};
|
||||
|
||||
const ZProductRecontactDaysInput = ZProduct.pick({ recontactDays: true });
|
||||
|
||||
type EditWaitingTimeFormValues = z.infer<typeof ZProductRecontactDaysInput>;
|
||||
|
||||
export const EditWaitingTimeForm: React.FC<EditWaitingTimeProps> = ({ product, environmentId }) => {
|
||||
const form = useForm<EditWaitingTimeFormValues>({
|
||||
defaultValues: {
|
||||
recontactDays: product.recontactDays,
|
||||
},
|
||||
resolver: zodResolver(ZProductRecontactDaysInput),
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const { isDirty, isSubmitting } = form.formState;
|
||||
|
||||
const updateWaitingTime: SubmitHandler<EditWaitingTimeFormValues> = async (data) => {
|
||||
try {
|
||||
const updatedProduct = await updateProductAction(environmentId, product.id, data);
|
||||
if (!!updatedProduct?.id) {
|
||||
toast.success("Waiting period updated successfully.");
|
||||
form.resetField("recontactDays", { defaultValue: updatedProduct.recontactDays });
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
className="flex w-full max-w-sm flex-col space-y-4"
|
||||
onSubmit={form.handleSubmit(updateWaitingTime)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recontactDays"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="recontactDays">Wait X days before showing next survey:</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
id="recontactDays"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") {
|
||||
field.onChange("");
|
||||
}
|
||||
|
||||
field.onChange(parseInt(value, 10));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
|
||||
export const SubmitButton = () => {
|
||||
const formStatus = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button type="submit" variant="darkCTA" size="sm" loading={formStatus.pending}>
|
||||
Update
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -15,8 +15,8 @@ import { SettingsId } from "@formbricks/ui/SettingsId";
|
||||
|
||||
import { SettingsCard } from "../../settings/components/SettingsCard";
|
||||
import { DeleteProduct } from "./components/DeleteProduct";
|
||||
import { EditProductName } from "./components/EditProductName";
|
||||
import { EditWaitingTime } from "./components/EditWaitingTime";
|
||||
import { EditProductNameForm } from "./components/EditProductNameForm";
|
||||
import { EditWaitingTimeForm } from "./components/EditWaitingTimeForm";
|
||||
|
||||
const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
const [, product, session, team] = await Promise.all([
|
||||
@@ -57,7 +57,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
</PageHeader>
|
||||
|
||||
<SettingsCard title="Product Name" description="Change your products name.">
|
||||
<EditProductName
|
||||
<EditProductNameForm
|
||||
environmentId={params.environmentId}
|
||||
product={product}
|
||||
isProductNameEditDisabled={isProductNameEditDisabled}
|
||||
@@ -66,7 +66,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
<SettingsCard
|
||||
title="Recontact Waiting Time"
|
||||
description="Control how frequently users can be surveyed across all surveys.">
|
||||
<EditWaitingTime environmentId={params.environmentId} product={product} />
|
||||
<EditWaitingTimeForm environmentId={params.environmentId} product={product} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="Delete Product"
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TPlacement } from "@formbricks/types/common";
|
||||
import { TProduct, TProductUpdateInput } from "@formbricks/types/product";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { getPlacementStyle } from "@formbricks/ui/PreviewSurvey/lib/utils";
|
||||
import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup";
|
||||
|
||||
import { updateProductAction } from "../actions";
|
||||
|
||||
const placements = [
|
||||
{ name: "Bottom Right", value: "bottomRight", disabled: false },
|
||||
{ name: "Top Right", value: "topRight", disabled: false },
|
||||
{ name: "Top Left", value: "topLeft", disabled: false },
|
||||
{ name: "Bottom Left", value: "bottomLeft", disabled: false },
|
||||
{ name: "Centered Modal", value: "center", disabled: false },
|
||||
];
|
||||
|
||||
interface EditPlacementProps {
|
||||
product: TProduct;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const EditPlacement = ({ product }: EditPlacementProps) => {
|
||||
const [currentPlacement, setCurrentPlacement] = useState<TPlacement>(product.placement);
|
||||
const [overlay, setOverlay] = useState(product.darkOverlay ? "darkOverlay" : "lightOverlay");
|
||||
const [clickOutside, setClickOutside] = useState(product.clickOutsideClose ? "allow" : "disallow");
|
||||
const [updatingPlacement, setUpdatingPlacement] = useState(false);
|
||||
const overlayStyle =
|
||||
currentPlacement === "center" && overlay === "darkOverlay" ? "bg-gray-700/80" : "bg-slate-200";
|
||||
|
||||
const handleUpdatePlacement = async () => {
|
||||
try {
|
||||
setUpdatingPlacement(true);
|
||||
let inputProduct: Partial<TProductUpdateInput> = {
|
||||
placement: currentPlacement,
|
||||
darkOverlay: overlay === "darkOverlay",
|
||||
clickOutsideClose: clickOutside === "allow",
|
||||
};
|
||||
|
||||
await updateProductAction(product.id, inputProduct);
|
||||
|
||||
toast.success("Placement updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setUpdatingPlacement(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full items-center">
|
||||
<div className="flex">
|
||||
<RadioGroup onValueChange={(e) => setCurrentPlacement(e as TPlacement)} value={currentPlacement}>
|
||||
{placements.map((placement) => (
|
||||
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id={placement.value} value={placement.value} disabled={placement.disabled} />
|
||||
<Label htmlFor={placement.value} className="text-slate-900">
|
||||
{placement.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<div
|
||||
className={cn(
|
||||
clickOutside === "disallow" ? "cursor-not-allowed" : "",
|
||||
"relative ml-8 h-40 w-full rounded",
|
||||
overlayStyle
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute h-16 w-16 cursor-default rounded bg-slate-700",
|
||||
getPlacementStyle(currentPlacement)
|
||||
)}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentPlacement === "center" && (
|
||||
<>
|
||||
<div className="mt-6 space-y-2">
|
||||
<Label className="font-semibold">Centered modal overlay color</Label>
|
||||
<RadioGroup onValueChange={(e) => setOverlay(e)} value={overlay} className="flex space-x-4">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="lightOverlay" value="lightOverlay" />
|
||||
<Label htmlFor="lightOverlay" className="text-slate-900">
|
||||
Light Overlay
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="darkOverlay" value="darkOverlay" />
|
||||
<Label htmlFor="darkOverlay" className="text-slate-900">
|
||||
Dark Overlay
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<Label className="font-semibold">Allow users to exit by clicking outside the study</Label>
|
||||
<RadioGroup
|
||||
onValueChange={(e) => setClickOutside(e)}
|
||||
value={clickOutside}
|
||||
className="flex space-x-4">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="disallow" value="disallow" />
|
||||
<Label htmlFor="disallow" className="text-slate-900">
|
||||
Don't Allow
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="allow" value="allow" />
|
||||
<Label htmlFor="allow" className="text-slate-900">
|
||||
Allow
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
className="mt-4"
|
||||
size="sm"
|
||||
loading={updatingPlacement}
|
||||
onClick={handleUpdatePlacement}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormProvider } from "@formbricks/ui/Form";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { getPlacementStyle } from "@formbricks/ui/PreviewSurvey/lib/utils";
|
||||
import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup";
|
||||
|
||||
import { updateProductAction } from "../actions";
|
||||
|
||||
const placements = [
|
||||
{ name: "Bottom Right", value: "bottomRight", disabled: false },
|
||||
{ name: "Top Right", value: "topRight", disabled: false },
|
||||
{ name: "Top Left", value: "topLeft", disabled: false },
|
||||
{ name: "Bottom Left", value: "bottomLeft", disabled: false },
|
||||
{ name: "Centered Modal", value: "center", disabled: false },
|
||||
];
|
||||
|
||||
interface EditPlacementProps {
|
||||
product: TProduct;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
const ZProductPlacementInput = z.object({
|
||||
placement: z.enum(["bottomRight", "topRight", "topLeft", "bottomLeft", "center"]),
|
||||
darkOverlay: z.boolean(),
|
||||
clickOutsideClose: z.boolean(),
|
||||
});
|
||||
|
||||
type EditPlacementFormValues = z.infer<typeof ZProductPlacementInput>;
|
||||
|
||||
export const EditPlacementForm = ({ product }: EditPlacementProps) => {
|
||||
const form = useForm<EditPlacementFormValues>({
|
||||
defaultValues: {
|
||||
placement: product.placement,
|
||||
darkOverlay: product.darkOverlay ?? false,
|
||||
clickOutsideClose: product.clickOutsideClose ?? false,
|
||||
},
|
||||
resolver: zodResolver(ZProductPlacementInput),
|
||||
});
|
||||
|
||||
const currentPlacement = form.watch("placement");
|
||||
const darkOverlay = form.watch("darkOverlay");
|
||||
const clickOutsideClose = form.watch("clickOutsideClose");
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const overlayStyle = currentPlacement === "center" && darkOverlay ? "bg-gray-700/80" : "bg-slate-200";
|
||||
|
||||
const onSubmit: SubmitHandler<EditPlacementFormValues> = async (data) => {
|
||||
try {
|
||||
await updateProductAction(product.id, {
|
||||
placement: data.placement,
|
||||
darkOverlay: data.darkOverlay,
|
||||
clickOutsideClose: data.clickOutsideClose,
|
||||
});
|
||||
|
||||
toast.success("Placement updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form className="w-full items-center" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="placement"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
className="h-full">
|
||||
{placements.map((placement) => (
|
||||
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem
|
||||
id={placement.value}
|
||||
value={placement.value}
|
||||
disabled={placement.disabled}
|
||||
checked={field.value === placement.value}
|
||||
/>
|
||||
<Label htmlFor={placement.value} className="text-slate-900">
|
||||
{placement.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
clickOutsideClose ? "" : "cursor-not-allowed",
|
||||
"relative ml-8 h-40 w-full rounded",
|
||||
overlayStyle
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute h-16 w-16 cursor-default rounded bg-slate-700",
|
||||
getPlacementStyle(currentPlacement)
|
||||
)}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentPlacement === "center" && (
|
||||
<>
|
||||
<div className="mt-6 space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="darkOverlay"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="font-semibold">Centered modal overlay color</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value === "darkOverlay");
|
||||
}}
|
||||
className="flex space-x-4">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="lightOverlay" value="lightOverlay" checked={!field.value} />
|
||||
<Label htmlFor="lightOverlay" className="text-slate-900">
|
||||
Light Overlay
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="darkOverlay" value="darkOverlay" checked={field.value} />
|
||||
<Label htmlFor="darkOverlay" className="text-slate-900">
|
||||
Dark Overlay
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clickOutsideClose"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="font-semibold">
|
||||
Allow users to exit by clicking outside the study
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value === "allow");
|
||||
}}
|
||||
className="flex space-x-4">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="disallow" value="disallow" checked={!field.value} />
|
||||
<Label htmlFor="disallow" className="text-slate-900">
|
||||
Don't Allow
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="allow" value="allow" checked={field.value} />
|
||||
<Label htmlFor="allow" className="text-slate-900">
|
||||
Allow
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button variant="darkCTA" className="mt-4 w-fit" size="sm" loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -19,7 +19,7 @@ import { PageHeader } from "@formbricks/ui/PageHeader";
|
||||
|
||||
import { SettingsCard } from "../../settings/components/SettingsCard";
|
||||
import { EditFormbricksBranding } from "./components/EditBranding";
|
||||
import { EditPlacement } from "./components/EditPlacement";
|
||||
import { EditPlacementForm } from "./components/EditPlacementForm";
|
||||
import { ThemeStyling } from "./components/ThemeStyling";
|
||||
|
||||
const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
@@ -77,7 +77,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
<SettingsCard
|
||||
title="In-app Survey Placement"
|
||||
description="Change where surveys will be shown in your web app.">
|
||||
<EditPlacement product={product} environmentId={params.environmentId} />
|
||||
<EditPlacementForm product={product} environmentId={params.environmentId} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="Formbricks Branding"
|
||||
|
||||
@@ -71,7 +71,7 @@ export const BulkInviteTab = ({ setOpen, onSubmit, canDoRoleManagement }: BulkIn
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="relative flex h-52 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-gray-300"
|
||||
className="relative flex h-52 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-slate-300 bg-slate-50 transition-colors hover:bg-slate-100"
|
||||
onClick={() => fileInputRef.current?.click()}>
|
||||
{csvFile ? (
|
||||
<XIcon
|
||||
@@ -97,16 +97,18 @@ export const BulkInviteTab = ({ setOpen, onSubmit, canDoRoleManagement }: BulkIn
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end pt-6">
|
||||
<div className="flex justify-end">
|
||||
<div className="flex space-x-2">
|
||||
<Link
|
||||
download
|
||||
href="/sample-csv/formbricks-team-members-template.csv"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
<Button variant="minimal">Download CSV template</Button>
|
||||
<Button variant="minimal" size="sm">
|
||||
Download CSV template
|
||||
</Button>
|
||||
</Link>
|
||||
<Button onClick={onImport} variant="darkCTA" disabled={!csvFile}>
|
||||
<Button onClick={onImport} size="sm" variant="darkCTA" disabled={!csvFile}>
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,7 @@ export const IndividualInviteTab = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end p-6">
|
||||
<div className="flex justify-end pt-4">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -24,6 +24,13 @@ export const POST = async (req: Request, context: Context): Promise<Response> =>
|
||||
const { userId, environmentId } = context.params;
|
||||
const jsonInput = await req.json();
|
||||
|
||||
// transform all attributes to string if attributes are present
|
||||
if (jsonInput.attributes) {
|
||||
for (const key in jsonInput.attributes) {
|
||||
jsonInput.attributes[key] = String(jsonInput.attributes[key]);
|
||||
}
|
||||
}
|
||||
|
||||
// validate using zod
|
||||
const inputValidation = z.object({ attributes: ZAttributes }).safeParse(jsonInput);
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export const POST = async (request: Request): Promise<Response> => {
|
||||
url: responseInput?.meta?.url,
|
||||
userAgent: {
|
||||
browser: agent?.browser.name,
|
||||
device: agent?.device.type,
|
||||
device: agent?.device.type || "desktop",
|
||||
os: agent?.os.name,
|
||||
},
|
||||
country: country,
|
||||
|
||||
@@ -82,7 +82,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
url: responseInput?.meta?.url,
|
||||
userAgent: {
|
||||
browser: agent?.browser.name,
|
||||
device: agent?.device.type,
|
||||
device: agent?.device.type || "desktop",
|
||||
os: agent?.os.name,
|
||||
},
|
||||
country: country,
|
||||
|
||||
@@ -114,7 +114,7 @@ export const GET = async (
|
||||
const noCodeActionClasses = actionClasses.filter((actionClass) => actionClass.type === "noCode");
|
||||
|
||||
// Define 'transformedSurveys' which can be an array of either TLegacySurvey or TSurvey.
|
||||
let transformedSurveys: TLegacySurvey[] | TSurvey[] = surveys;
|
||||
let transformedSurveys: TLegacySurvey[] | TSurvey[] = filteredSurveys;
|
||||
let state: TJsWebsiteStateSync | TJsWebsiteLegacyStateSync = {
|
||||
surveys: !isInAppSurveyLimitReached ? transformedSurveys : [],
|
||||
actionClasses,
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@formbricks/tailwind-config": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"@hookform/resolvers": "^3.4.2",
|
||||
"@json2csv/node": "^7.0.6",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.46.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.51.1",
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: "3.3"
|
||||
|
||||
# This should be the same as below if you are running via docker compose up
|
||||
x-webapp-url: &webapp_url http://localhost:3000
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ x-environment: &environment
|
||||
######################################################## REQUIRED ########################################################
|
||||
|
||||
# The url of your Formbricks instance used in the admin panel
|
||||
# Set this to your public-facing URL, e.g., https://example.com
|
||||
WEBAPP_URL:
|
||||
|
||||
# PostgreSQL DB for Formbricks to connect to
|
||||
@@ -16,7 +17,7 @@ x-environment: &environment
|
||||
|
||||
# Set this to your public-facing URL, e.g., https://example.com
|
||||
# You do not need the NEXTAUTH_URL environment variable in Vercel.
|
||||
NEXTAUTH_URL: http://localhost:3000
|
||||
NEXTAUTH_URL:
|
||||
|
||||
# Encryption Key is used for 2FA & Single use URLs for Link Surveys
|
||||
# You can use: $(openssl rand -hex 32) to generate one
|
||||
|
||||
@@ -16,11 +16,17 @@ export class AttributeAPI {
|
||||
async update(
|
||||
attributeUpdateInput: Omit<TAttributeUpdateInput, "environmentId">
|
||||
): Promise<Result<{ changed: boolean; message: string }, NetworkError | Error>> {
|
||||
// transform all attributes to string if attributes are present into a new attributes copy
|
||||
const attributes: { [key: string]: string } = {};
|
||||
for (const key in attributeUpdateInput.attributes) {
|
||||
attributes[key] = String(attributeUpdateInput.attributes[key]);
|
||||
}
|
||||
|
||||
return makeRequest(
|
||||
this.apiHost,
|
||||
`/api/v1/client/${this.environmentId}/people/${attributeUpdateInput.userId}/attributes`,
|
||||
"PUT",
|
||||
{ attributes: attributeUpdateInput.attributes }
|
||||
{ attributes }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ import { AppConfig } from "./config";
|
||||
const appConfig = AppConfig.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
|
||||
export const updateAttribute = async (key: string, value: string): Promise<Result<void, NetworkError>> => {
|
||||
export const updateAttribute = async (
|
||||
key: string,
|
||||
value: string | number
|
||||
): Promise<Result<void, NetworkError>> => {
|
||||
const { apiHost, environmentId, userId } = appConfig.get();
|
||||
|
||||
const api = new FormbricksAPI({
|
||||
@@ -121,7 +124,7 @@ export const setAttributeInApp = async (
|
||||
return okVoid();
|
||||
}
|
||||
|
||||
const result = await updateAttribute(key, value.toString());
|
||||
const result = await updateAttribute(key, value);
|
||||
|
||||
if (result.ok) {
|
||||
// udpdate attribute in config
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@formbricks/js",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
|
||||
"homepage": "https://formbricks.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TFormbricksApp } from "@formbricks/js-core/app";
|
||||
import { TFormbricksWebsite } from "@formbricks/js-core/website";
|
||||
|
||||
import { Result, wrapThrowsAsync } from "../../types/errorHandlers";
|
||||
import { loadFormbricksToProxy } from "./shared/loadFormbricks";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -9,97 +9,9 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
// load the sdk, return the result
|
||||
const loadFormbricksAppSDK = async (apiHost: string): Promise<Result<void>> => {
|
||||
if (!window.formbricks) {
|
||||
const res = await fetch(`${apiHost}/api/packages/app`);
|
||||
|
||||
// failed to fetch the app package
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: new Error("Failed to load Formbricks App SDK") };
|
||||
}
|
||||
|
||||
const sdkScript = await res.text();
|
||||
const scriptTag = document.createElement("script");
|
||||
scriptTag.innerHTML = sdkScript;
|
||||
document.head.appendChild(scriptTag);
|
||||
|
||||
const getFormbricks = async () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (window.formbricks) {
|
||||
clearInterval(checkInterval);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(checkInterval);
|
||||
reject(new Error("Formbricks App SDK loading timed out"));
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
try {
|
||||
await getFormbricks();
|
||||
return { ok: true, data: undefined };
|
||||
} catch (error: any) {
|
||||
// formbricks loading failed, return the error
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(error.message ?? "Failed to load Formbricks App SDK"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, data: undefined };
|
||||
};
|
||||
|
||||
type FormbricksAppMethods = {
|
||||
[K in keyof TFormbricksApp]: TFormbricksApp[K] extends Function ? K : never;
|
||||
}[keyof TFormbricksApp];
|
||||
|
||||
const formbricksProxyHandler: ProxyHandler<TFormbricksApp> = {
|
||||
get(_target, prop, _receiver) {
|
||||
return async (...args: any[]) => {
|
||||
if (!window.formbricks) {
|
||||
if (prop !== "init") {
|
||||
console.error(
|
||||
"🧱 Formbricks - Global error: You need to call formbricks.init before calling any other method"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// still need to check if the apiHost is passed
|
||||
if (!args[0]) {
|
||||
console.error("🧱 Formbricks - Global error: You need to pass the apiHost as the first argument");
|
||||
return;
|
||||
}
|
||||
|
||||
const { apiHost } = args[0];
|
||||
const loadSDKResult = await wrapThrowsAsync(loadFormbricksAppSDK)(apiHost);
|
||||
|
||||
if (!loadSDKResult.ok) {
|
||||
console.error(`🧱 Formbricks - Global error: ${loadSDKResult.error.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
if (window.formbricks && typeof window.formbricks[prop as FormbricksAppMethods] !== "function") {
|
||||
console.error(
|
||||
`🧱 Formbricks - Global error: Formbricks App SDK does not support method ${String(prop)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-expect-error
|
||||
return (window.formbricks[prop as FormbricksAppMethods] as Function)(...args);
|
||||
} catch (error) {
|
||||
console.error(`Something went wrong: ${error}`);
|
||||
return;
|
||||
}
|
||||
};
|
||||
return (...args: any[]) => loadFormbricksToProxy(prop as string, "app", ...args);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as formbricksApp } from "./app";
|
||||
export { default as formbricksWebsite } from "./website";
|
||||
38
packages/js/src/methodQueue.ts
Normal file
38
packages/js/src/methodQueue.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Simple queue for formbricks methods
|
||||
|
||||
export class MethodQueue {
|
||||
private queue: (() => Promise<void>)[] = [];
|
||||
private isExecuting = false;
|
||||
|
||||
add = (method: () => Promise<void>) => {
|
||||
this.queue.push(method);
|
||||
this.run();
|
||||
};
|
||||
|
||||
private runNext = async () => {
|
||||
if (this.isExecuting) return;
|
||||
|
||||
const method = this.queue.shift();
|
||||
if (method) {
|
||||
this.isExecuting = true;
|
||||
try {
|
||||
await method();
|
||||
} finally {
|
||||
this.isExecuting = false;
|
||||
if (this.queue.length > 0) {
|
||||
this.runNext();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
run = async () => {
|
||||
if (!this.isExecuting && this.queue.length > 0) {
|
||||
await this.runNext();
|
||||
}
|
||||
};
|
||||
|
||||
clear = () => {
|
||||
this.queue = [];
|
||||
};
|
||||
}
|
||||
122
packages/js/src/shared/loadFormbricks.ts
Normal file
122
packages/js/src/shared/loadFormbricks.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Result, wrapThrowsAsync } from "../../../types/errorHandlers";
|
||||
import { MethodQueue } from "../methodQueue";
|
||||
|
||||
let isInitializing = false;
|
||||
let isInitialized = false;
|
||||
const methodQueue = new MethodQueue();
|
||||
|
||||
// Load the SDK, return the result
|
||||
const loadFormbricksSDK = async (apiHost: string, sdkType: "app" | "website"): Promise<Result<void>> => {
|
||||
if (!window.formbricks) {
|
||||
const res = await fetch(`${apiHost}/api/packages/${sdkType}`);
|
||||
|
||||
// Failed to fetch the app package
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: new Error(`Failed to load Formbricks ${sdkType} SDK`) };
|
||||
}
|
||||
|
||||
const sdkScript = await res.text();
|
||||
const scriptTag = document.createElement("script");
|
||||
scriptTag.innerHTML = sdkScript;
|
||||
document.head.appendChild(scriptTag);
|
||||
|
||||
const getFormbricks = async () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (window.formbricks) {
|
||||
clearInterval(checkInterval);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(checkInterval);
|
||||
reject(new Error(`Formbricks ${sdkType} SDK loading timed out`));
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
try {
|
||||
await getFormbricks();
|
||||
return { ok: true, data: undefined };
|
||||
} catch (error: any) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(error.message ?? `Failed to load Formbricks ${sdkType} SDK`),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, data: undefined };
|
||||
};
|
||||
|
||||
// TODO: @pandeymangg - Fix these types
|
||||
// type FormbricksAppMethods = {
|
||||
// [K in keyof TFormbricksApp]: TFormbricksApp[K] extends Function ? K : never;
|
||||
// }[keyof TFormbricksApp];
|
||||
|
||||
// type FormbricksWebsiteMethods = {
|
||||
// [K in keyof TFormbricksWebsite]: TFormbricksWebsite[K] extends Function ? K : never;
|
||||
// }[keyof TFormbricksWebsite];
|
||||
|
||||
export const loadFormbricksToProxy = async (prop: string, sdkType: "app" | "website", ...args: any[]) => {
|
||||
const executeMethod = async () => {
|
||||
try {
|
||||
// @ts-expect-error
|
||||
return await (window.formbricks[prop] as Function)(...args);
|
||||
} catch (error) {
|
||||
console.error(`🧱 Formbricks - Global error: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
if (!isInitialized) {
|
||||
if (isInitializing) {
|
||||
methodQueue.add(executeMethod);
|
||||
} else {
|
||||
if (prop === "init") {
|
||||
isInitializing = true;
|
||||
|
||||
const initialize = async () => {
|
||||
const { apiHost } = args[0];
|
||||
const loadSDKResult = await wrapThrowsAsync(loadFormbricksSDK)(apiHost, sdkType);
|
||||
|
||||
if (!loadSDKResult.ok) {
|
||||
isInitializing = false;
|
||||
console.error(`🧱 Formbricks - Global error: ${loadSDKResult.error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await (window.formbricks[prop] as Function)(...args);
|
||||
isInitialized = true;
|
||||
isInitializing = false;
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
isInitializing = false;
|
||||
console.error(`🧱 Formbricks - Global error: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
methodQueue.add(initialize);
|
||||
} else {
|
||||
console.error(
|
||||
"🧱 Formbricks - Global error: You need to call formbricks.init before calling any other method"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// @ts-expect-error
|
||||
if (window.formbricks && typeof window.formbricks[prop] !== "function") {
|
||||
console.error(
|
||||
`🧱 Formbricks - Global error: Formbricks ${sdkType} SDK does not support method ${String(prop)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
methodQueue.add(executeMethod);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TFormbricksApp } from "@formbricks/js-core/app";
|
||||
import { TFormbricksWebsite } from "@formbricks/js-core/website";
|
||||
|
||||
import { Result, wrapThrowsAsync } from "../../types/errorHandlers";
|
||||
import { loadFormbricksToProxy } from "./shared/loadFormbricks";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -9,97 +9,11 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
// load the sdk, return the result
|
||||
const loadFormbricksWebsiteSDK = async (apiHost: string): Promise<Result<void>> => {
|
||||
if (!window.formbricks) {
|
||||
const res = await fetch(`${apiHost}/api/packages/website`);
|
||||
|
||||
// failed to fetch the app package
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: new Error("Failed to load Formbricks Website SDK") };
|
||||
}
|
||||
|
||||
const sdkScript = await res.text();
|
||||
const scriptTag = document.createElement("script");
|
||||
scriptTag.innerHTML = sdkScript;
|
||||
document.head.appendChild(scriptTag);
|
||||
|
||||
const getFormbricks = async () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (window.formbricks) {
|
||||
clearInterval(checkInterval);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(checkInterval);
|
||||
reject(new Error("Formbricks Website SDK loading timed out"));
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
try {
|
||||
await getFormbricks();
|
||||
return { ok: true, data: undefined };
|
||||
} catch (error: any) {
|
||||
// formbricks loading failed, return the error
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(error.message ?? "Failed to load Formbricks Website SDK"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, data: undefined };
|
||||
};
|
||||
|
||||
type FormbricksWebsiteMethods = {
|
||||
[K in keyof TFormbricksWebsite]: TFormbricksWebsite[K] extends Function ? K : never;
|
||||
}[keyof TFormbricksWebsite];
|
||||
|
||||
const formbricksProxyHandler: ProxyHandler<TFormbricksWebsite> = {
|
||||
get(_target, prop, _receiver) {
|
||||
return async (...args: any[]) => {
|
||||
if (!window.formbricks) {
|
||||
if (prop !== "init") {
|
||||
console.error(
|
||||
"🧱 Formbricks - Global error: You need to call formbricks.init before calling any other method"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// still need to check if the apiHost is passed
|
||||
if (!args[0]) {
|
||||
console.error("🧱 Formbricks - Global error: You need to pass the apiHost as the first argument");
|
||||
return;
|
||||
}
|
||||
|
||||
const { apiHost } = args[0];
|
||||
const loadSDKResult = await wrapThrowsAsync(loadFormbricksWebsiteSDK)(apiHost);
|
||||
|
||||
if (!loadSDKResult.ok) {
|
||||
console.error(`🧱 Formbricks - Global error: ${loadSDKResult.error.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (window.formbricks && typeof window.formbricks[prop as FormbricksWebsiteMethods] !== "function") {
|
||||
console.error(
|
||||
`🧱 Formbricks - Global error: Formbricks Website SDK does not support method ${String(prop)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
return (window.formbricks[prop as FormbricksWebsiteMethods] as Function)(...args);
|
||||
} catch (error) {
|
||||
console.error(`🧱 Formbricks - Global error: Something went wrong: ${error}`);
|
||||
return;
|
||||
}
|
||||
};
|
||||
return (...args: any[]) => loadFormbricksToProxy(prop as string, "website", ...args);
|
||||
},
|
||||
};
|
||||
|
||||
const formbricksApp: TFormbricksWebsite = new Proxy({} as TFormbricksWebsite, formbricksProxyHandler);
|
||||
export default formbricksApp;
|
||||
const formbricksWebsite: TFormbricksWebsite = new Proxy({} as TFormbricksWebsite, formbricksProxyHandler);
|
||||
export default formbricksWebsite;
|
||||
|
||||
@@ -208,8 +208,8 @@ export const RatingQuestion = ({
|
||||
))}
|
||||
</div>
|
||||
<div className="text-subheading mt-4 flex justify-between px-1.5 text-xs leading-6">
|
||||
<p className="w-1/2 text-left">{getLocalizedValue(question.lowerLabel, "default")}</p>
|
||||
<p className="w-1/2 text-right">{getLocalizedValue(question.upperLabel, "default")}</p>
|
||||
<p className="w-1/2 text-left">{getLocalizedValue(question.lowerLabel, languageCode)}</p>
|
||||
<p className="w-1/2 text-right">{getLocalizedValue(question.upperLabel, languageCode)}</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
interface ScrollableContainerProps {
|
||||
@@ -58,10 +59,8 @@ export const ScrollableContainer = ({ children }: ScrollableContainerProps) => {
|
||||
scrollbarGutter: "stable both-edges",
|
||||
maxHeight: isSurveyPreview ? "40dvh" : "60dvh",
|
||||
}}
|
||||
className={`overflow-${isOverflowHidden ? "hidden" : "auto"} px-4 pb-1`}
|
||||
className={cn("overflow-auto px-4 pb-1", isOverflowHidden ? "no-scrollbar" : "bg-survey-bg")}
|
||||
onMouseEnter={() => toggleOverflow(false)}
|
||||
onTouchStart={() => toggleOverflow(false)}
|
||||
onTouchEnd={() => toggleOverflow(true)}
|
||||
onMouseLeave={() => toggleOverflow(true)}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -130,6 +130,7 @@ export const StackedCardsContainer = ({
|
||||
<div style={{ height: cardHeight }}></div>
|
||||
{cardArrangement === "simple" ? (
|
||||
<div
|
||||
className="w-full"
|
||||
style={{
|
||||
...borderStyles,
|
||||
}}>
|
||||
|
||||
@@ -104,3 +104,18 @@ p.fb-editor-paragraph {
|
||||
width: 0%;
|
||||
}
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none !important; /* Internet Explorer 10+ */
|
||||
scrollbar-width: thin !important; /* Firefox */
|
||||
scrollbar-color: transparent transparent !important; /* Firefox */
|
||||
|
||||
/* Chrome, Edge, and Safari */
|
||||
&::-webkit-scrollbar {
|
||||
width: 0 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
export const ZAttributeUpdateInput = z.object({
|
||||
environmentId: z.string().cuid2(),
|
||||
userId: z.string(),
|
||||
attributes: z.record(z.string()),
|
||||
attributes: z.record(z.union([z.string(), z.number()])),
|
||||
});
|
||||
|
||||
export type TAttributeUpdateInput = z.infer<typeof ZAttributeUpdateInput>;
|
||||
|
||||
@@ -41,10 +41,14 @@ export const ZProduct = z.object({
|
||||
id: z.string().cuid2(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
name: z.string(),
|
||||
name: z.string().trim().min(1, { message: "Product name cannot be empty" }),
|
||||
teamId: z.string(),
|
||||
styling: ZProductStyling,
|
||||
recontactDays: z.number().int(),
|
||||
recontactDays: z
|
||||
.number({ message: "Recontact days is required" })
|
||||
.int()
|
||||
.min(0, { message: "Must be a positive number" })
|
||||
.max(365, { message: "Must be less than 365" }),
|
||||
inAppSurveyBranding: z.boolean(),
|
||||
linkSurveyBranding: z.boolean(),
|
||||
placement: ZPlacement,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Label } from "@radix-ui/react-dropdown-menu";
|
||||
import clsx from "clsx";
|
||||
import { Control, Controller, UseFormRegister } from "react-hook-form";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
|
||||
import { AdvancedOptionToggle } from "../../AdvancedOptionToggle";
|
||||
@@ -42,9 +42,9 @@ export const PageUrlSelector = ({
|
||||
title="Page URL"
|
||||
description="If a user visits a specific URL"
|
||||
childBorder={true}>
|
||||
<div className="col-span-1 space-y-3 p-4">
|
||||
<div className="grid grid-cols-3 gap-x-8">
|
||||
<div className="col-span-1">
|
||||
<div className="col-span-1 w-full space-y-3 p-4">
|
||||
<div className="flex w-full items-end gap-2">
|
||||
<div>
|
||||
<Label>URL</Label>
|
||||
<Controller
|
||||
name="noCodeConfig.pageUrl.rule"
|
||||
@@ -66,7 +66,7 @@ export const PageUrlSelector = ({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-end">
|
||||
<div className="flex flex-1 items-end">
|
||||
<Input
|
||||
type="text"
|
||||
className="bg-white"
|
||||
@@ -81,7 +81,7 @@ export const PageUrlSelector = ({
|
||||
Enter a URL to see if a user visiting it would be tracked.
|
||||
</div>
|
||||
<div className=" rounded bg-slate-50">
|
||||
<div className="mt-1 flex">
|
||||
<div className="mt-1 flex items-end">
|
||||
<Input
|
||||
type="text"
|
||||
value={testUrl}
|
||||
@@ -90,7 +90,7 @@ export const PageUrlSelector = ({
|
||||
setTestUrl(e.target.value);
|
||||
setIsMatch("default");
|
||||
}}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
isMatch === "yes"
|
||||
? "border-green-500 bg-green-50"
|
||||
: isMatch === "no"
|
||||
|
||||
154
packages/ui/Form/index.tsx
Normal file
154
packages/ui/Form/index.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
|
||||
import { Label } from "../Label";
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState, formState } = useFormContext();
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
|
||||
|
||||
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
FormItem.displayName = "FormItem";
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return <Label ref={ref} className={cn(error && "text-error", className)} htmlFor={formItemId} {...props} />;
|
||||
});
|
||||
FormLabel.displayName = "FormLabel";
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormControl.displayName = "FormControl";
|
||||
|
||||
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
FormDescription.displayName = "FormDescription";
|
||||
|
||||
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message) : children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p ref={ref} id={formMessageId} className={cn("text-error text-sm", className)} {...props}>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
);
|
||||
FormMessage.displayName = "FormMessage";
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
FormProvider,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
};
|
||||
@@ -17,7 +17,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, isInv
|
||||
className={cn(
|
||||
"focus:border-brand-dark flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300",
|
||||
className,
|
||||
isInvalid && "border border-red-600 focus:border-red-600"
|
||||
isInvalid && "border-error focus:border-error border"
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
|
||||
@@ -50,8 +50,7 @@ const DialogContent = React.forwardRef<
|
||||
`${noPadding ? "" : "px-4 pb-4 pt-5 sm:p-6"}`,
|
||||
"data-[state='closed']:animate-fadeOut data-[state='open']:animate-fadeIn",
|
||||
size && sizeClassName && sizeClassName[size],
|
||||
className,
|
||||
"max-h-screen overflow-y-auto"
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
onPointerDownOutside={(e) => {
|
||||
|
||||
@@ -301,7 +301,7 @@ export const SingleResponseCard = ({
|
||||
<div className={clsx("group relative", isOpen && "min-h-[300px]")}>
|
||||
<div
|
||||
className={clsx(
|
||||
"relative z-30 my-6 rounded-xl border border-slate-200 bg-white shadow-sm transition-all",
|
||||
"relative z-20 my-6 rounded-xl border border-slate-200 bg-white shadow-sm transition-all",
|
||||
pageType === "response" &&
|
||||
(isOpen
|
||||
? "w-3/4"
|
||||
|
||||
@@ -128,8 +128,8 @@ export const SurveyCard = ({
|
||||
key={survey.id}
|
||||
className="relative grid w-full grid-cols-8 place-items-center gap-3 rounded-xl border border-slate-200 bg-white p-4
|
||||
shadow-sm transition-all ease-in-out hover:scale-[101%]">
|
||||
<div className="col-span-2 flex max-w-full items-center justify-self-start truncate whitespace-nowrap text-sm font-medium text-slate-900">
|
||||
{survey.name}
|
||||
<div className="col-span-2 flex max-w-full items-center justify-self-start text-sm font-medium text-slate-900">
|
||||
<div className="w-full truncate">{survey.name}</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
@@ -333,6 +333,9 @@ importers:
|
||||
'@formbricks/ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/ui
|
||||
'@hookform/resolvers':
|
||||
specifier: ^3.4.2
|
||||
version: 3.4.2(react-hook-form@7.51.4)
|
||||
'@json2csv/node':
|
||||
specifier: ^7.0.6
|
||||
version: 7.0.6
|
||||
@@ -3429,6 +3432,14 @@ packages:
|
||||
tailwindcss: 3.4.3
|
||||
dev: false
|
||||
|
||||
/@hookform/resolvers@3.4.2(react-hook-form@7.51.4):
|
||||
resolution: {integrity: sha512-1m9uAVIO8wVf7VCDAGsuGA0t6Z3m6jVGAN50HkV9vYLl0yixKK/Z1lr01vaRvYCkIKGoy1noVRxMzQYb4y/j1Q==}
|
||||
peerDependencies:
|
||||
react-hook-form: ^7.0.0
|
||||
dependencies:
|
||||
react-hook-form: 7.51.4(react@18.3.1)
|
||||
dev: false
|
||||
|
||||
/@httptoolkit/websocket-stream@6.0.1:
|
||||
resolution: {integrity: sha512-A0NOZI+Glp3Xgcz6Na7i7o09+/+xm2m0UCU8gdtM2nIv6/cjLmhMZMqehSpTlgbx9omtLmV8LVqOskPEyWnmZQ==}
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user